setiastrosuitepro 1.7.5.post1__py3-none-any.whl → 1.8.0.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (27) hide show
  1. setiastro/saspro/_generated/build_info.py +2 -2
  2. setiastro/saspro/accel_installer.py +21 -8
  3. setiastro/saspro/accel_workers.py +11 -12
  4. setiastro/saspro/comet_stacking.py +113 -85
  5. setiastro/saspro/cosmicclarity.py +604 -826
  6. setiastro/saspro/cosmicclarity_engines/benchmark_engine.py +732 -0
  7. setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +576 -0
  8. setiastro/saspro/cosmicclarity_engines/denoise_engine.py +567 -0
  9. setiastro/saspro/cosmicclarity_engines/satellite_engine.py +620 -0
  10. setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +587 -0
  11. setiastro/saspro/cosmicclarity_engines/superres_engine.py +412 -0
  12. setiastro/saspro/gui/main_window.py +14 -0
  13. setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
  14. setiastro/saspro/model_manager.py +324 -0
  15. setiastro/saspro/model_workers.py +102 -0
  16. setiastro/saspro/ops/benchmark.py +320 -0
  17. setiastro/saspro/ops/settings.py +407 -10
  18. setiastro/saspro/remove_stars.py +424 -442
  19. setiastro/saspro/resources.py +73 -10
  20. setiastro/saspro/runtime_torch.py +107 -22
  21. setiastro/saspro/signature_insert.py +14 -3
  22. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/METADATA +2 -1
  23. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/RECORD +27 -18
  24. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/WHEEL +0 -0
  25. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/entry_points.txt +0 -0
  26. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/LICENSE +0 -0
  27. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/license.txt +0 -0
@@ -7,13 +7,18 @@ import stat
7
7
  import tempfile
8
8
  import numpy as np
9
9
 
10
- from PyQt6.QtCore import QThread, pyqtSignal
10
+ from PyQt6.QtCore import QThread, pyqtSignal, Qt
11
11
  from PyQt6.QtWidgets import (
12
12
  QInputDialog, QMessageBox, QFileDialog,
13
- QDialog, QVBoxLayout, QTextEdit, QPushButton,
13
+ QDialog, QVBoxLayout, QTextEdit, QPushButton, QProgressBar,
14
14
  QLabel, QComboBox, QCheckBox, QSpinBox, QFormLayout, QDialogButtonBox, QWidget, QHBoxLayout
15
15
  )
16
16
 
17
+ from setiastro.saspro.cosmicclarity_engines.darkstar_engine import (
18
+ darkstar_starremoval_rgb01,
19
+ DarkStarParams,
20
+ )
21
+
17
22
  # use your legacy I/O functions (as requested)
18
23
  from setiastro.saspro.legacy.image_manager import save_image, load_image
19
24
  import glob
@@ -25,6 +30,7 @@ except Exception:
25
30
  # Shared utilities
26
31
  from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
27
32
 
33
+
28
34
  _MAD_NORM = 1.4826
29
35
 
30
36
  # --------- deterministic, invertible stretch used for StarNet ----------
@@ -393,55 +399,6 @@ def starnet_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="
393
399
 
394
400
  return result
395
401
 
396
-
397
- def darkstar_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="comet",
398
- disable_gpu=False, mode="unscreen", stride=512) -> np.ndarray:
399
- """
400
- Save arr -> run DarkStar -> load starless -> return starless RGB float32 [0..1].
401
- """
402
- exe, base = _resolve_darkstar_exe(type("dummy", (), {"settings": settings}) )
403
- if not exe or not base:
404
- raise RuntimeError("Cosmic Clarity DarkStar executable not configured.")
405
- arr = np.asarray(arr_rgb01, dtype=np.float32)
406
- was_single = (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1)
407
- input_dir = os.path.join(base, "input")
408
- output_dir = os.path.join(base, "output")
409
- os.makedirs(input_dir, exist_ok=True)
410
- os.makedirs(output_dir, exist_ok=True)
411
- _purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
412
-
413
- in_path = os.path.join(input_dir, f"{tmp_prefix}_in.tif")
414
- save_image(
415
- arr, in_path,
416
- original_format="tif", bit_depth="32-bit floating point",
417
- original_header=None, is_mono=was_single, image_meta=None, file_meta=None
418
- )
419
-
420
- args = []
421
- if disable_gpu: args.append("--disable_gpu")
422
- args += ["--star_removal_mode", mode, "--chunk_size", str(int(stride))]
423
- import subprocess
424
- rc = subprocess.call([exe] + args, cwd=output_dir)
425
- if rc != 0:
426
- _safe_rm(in_path); raise RuntimeError(f"DarkStar failed rc={rc}")
427
-
428
- starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
429
- starless, _, _, _ = load_image(starless_path)
430
- if starless is None:
431
- _safe_rm(in_path); raise RuntimeError("DarkStar produced no starless image.")
432
- if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
433
- starless = np.stack([starless] * 3, axis=-1)
434
- starless = np.clip(starless.astype(np.float32, copy=False), 0.0, 1.0)
435
-
436
- # If the source was mono, collapse back to single channel
437
- if was_single and starless.ndim == 3:
438
- starless = starless.mean(axis=2)
439
-
440
- # cleanup typical outputs
441
- _purge_darkstar_io(base, prefix="imagetoremovestars", clear_input=True, clear_output=True)
442
- return starless
443
-
444
-
445
402
  # ------------------------------------------------------------
446
403
  # Public entry
447
404
  # ------------------------------------------------------------
@@ -559,21 +516,20 @@ def _inverse_statstretch_from_starless(starless_s01: np.ndarray, meta: dict) ->
559
516
  out = out + bp
560
517
  return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
561
518
 
562
-
563
- # ------------------------------------------------------------
564
- # StarNet (SASv2-like: 16-bit TIFF in StarNet folder)
565
- # ------------------------------------------------------------
566
519
  def _run_starnet(main, doc):
567
520
  import os
568
521
  import platform
569
522
  import numpy as np
523
+ import re
570
524
  from PyQt6.QtWidgets import QFileDialog, QMessageBox
571
525
 
572
526
  # --- Resolve StarNet exe, persist in settings
573
527
  exe = _get_setting_any(getattr(main, "settings", None),
574
528
  ("starnet/exe_path", "paths/starnet"), "")
575
529
  if not exe or not os.path.exists(exe):
576
- exe_path, _ = QFileDialog.getOpenFileName(main, "Select StarNet Executable", "", "Executable Files (*)")
530
+ exe_path, _ = QFileDialog.getOpenFileName(
531
+ main, "Select StarNet Executable", "", "Executable Files (*)"
532
+ )
577
533
  if not exe_path:
578
534
  return
579
535
  exe = exe_path
@@ -587,8 +543,10 @@ def _run_starnet(main, doc):
587
543
 
588
544
  sysname = platform.system()
589
545
  if sysname not in ("Windows", "Darwin", "Linux"):
590
- QMessageBox.critical(main, "Unsupported OS",
591
- f"The current operating system '{sysname}' is not supported.")
546
+ QMessageBox.critical(
547
+ main, "Unsupported OS",
548
+ f"The current operating system '{sysname}' is not supported."
549
+ )
592
550
  return
593
551
 
594
552
  # --- Ask linearity (SASv2 behavior)
@@ -598,7 +556,9 @@ def _run_starnet(main, doc):
598
556
  QMessageBox.StandardButton.No
599
557
  )
600
558
  is_linear = (reply == QMessageBox.StandardButton.Yes)
601
- did_stretch = is_linear
559
+ did_stretch = is_linear
560
+
561
+ # stash params for replay-last
602
562
  try:
603
563
  main._last_remove_stars_params = {
604
564
  "engine": "StarNet",
@@ -608,6 +568,7 @@ def _run_starnet(main, doc):
608
568
  }
609
569
  except Exception:
610
570
  pass
571
+
611
572
  # 🔁 Record headless command for Replay Last
612
573
  try:
613
574
  main._last_headless_command = {
@@ -623,35 +584,35 @@ def _run_starnet(main, doc):
623
584
  f"{'yes' if is_linear else 'no'})"
624
585
  )
625
586
  except Exception:
626
- pass
627
- # --- Ensure RGB float32 in safe range (without expanding yet)
628
- # Starnet needs RGB eventually, but we can compute stats/normalization on mono
587
+ pass
588
+
589
+ # --- Ensure float32 and sane values (no forced RGB expansion yet)
629
590
  src = np.asarray(doc.image)
630
591
  if src.ndim == 3 and src.shape[2] == 1:
631
- # standardizing shape is cheap
632
592
  processing_image = src[..., 0]
633
593
  else:
634
594
  processing_image = src
635
-
636
- processing_image = np.nan_to_num(processing_image.astype(np.float32, copy=False),
637
- nan=0.0, posinf=0.0, neginf=0.0)
595
+
596
+ processing_image = np.nan_to_num(
597
+ processing_image.astype(np.float32, copy=False),
598
+ nan=0.0, posinf=0.0, neginf=0.0
599
+ )
638
600
 
639
601
  # --- Scale normalization if >1.0
640
- scale_factor = float(np.max(processing_image))
602
+ scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
641
603
  if scale_factor > 1.0:
642
604
  processing_norm = processing_image / scale_factor
643
605
  else:
644
606
  processing_norm = processing_image
645
607
 
646
- # --- Build input/output paths
608
+ # --- Build input/output paths (StarNet folder)
647
609
  starnet_dir = os.path.dirname(exe) or os.getcwd()
648
- input_image_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
610
+ input_image_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
649
611
  output_image_path = os.path.join(starnet_dir, "starless.tif")
650
612
 
651
- # --- Prepare input for StarNet (Siril-style MTF pre-stretch for linear data) ---
613
+ # --- Prepare input for StarNet (Siril-style unlinked MTF pre-stretch for linear data) ---
652
614
  img_for_starnet = processing_norm
653
615
  if is_linear:
654
- # Siril-style unlinked MTF params from linear normalized image
655
616
  mtf_params = _mtf_params_unlinked(processing_norm, shadows_clipping=-2.8, targetbg=0.25)
656
617
  img_for_starnet = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
657
618
 
@@ -668,20 +629,24 @@ def _run_starnet(main, doc):
668
629
  pass
669
630
  else:
670
631
  # non-linear: do not try to invert any pre-stretch later
671
- if hasattr(main, "_starnet_stat_meta"):
672
- delattr(main, "_starnet_stat_meta")
673
-
632
+ try:
633
+ if hasattr(main, "_starnet_stat_meta"):
634
+ delattr(main, "_starnet_stat_meta")
635
+ except Exception:
636
+ pass
674
637
 
675
- # --- Write TIFF for StarNet
638
+ # --- Write TIFF for StarNet (16-bit)
676
639
  try:
677
- save_image(img_for_starnet, input_image_path,
678
- original_format="tif", bit_depth="16-bit",
679
- original_header=None, is_mono=False, image_meta=None, file_meta=None)
640
+ save_image(
641
+ img_for_starnet, input_image_path,
642
+ original_format="tif", bit_depth="16-bit",
643
+ original_header=None, is_mono=False, image_meta=None, file_meta=None
644
+ )
680
645
  except Exception as e:
681
646
  QMessageBox.critical(main, "StarNet", f"Failed to write input TIFF:\n{e}")
682
647
  return
683
648
 
684
- # --- Launch StarNet in a worker (keeps your progress dialog)
649
+ # --- Build command
685
650
  exe_name = os.path.basename(exe).lower()
686
651
  if sysname in ("Windows", "Linux"):
687
652
  command = [exe, input_image_path, output_image_path, "256"]
@@ -691,29 +656,122 @@ def _run_starnet(main, doc):
691
656
  else:
692
657
  command = [exe, input_image_path, output_image_path]
693
658
 
659
+ # --- Progress dialog + worker
694
660
  dlg = _ProcDialog(main, title="StarNet Progress")
661
+ dlg.reset_progress("Starting StarNet…")
662
+ dlg.pbar.setRange(0, 100)
663
+ dlg.pbar.setValue(0)
664
+ dlg.pbar.setFormat("0%")
665
+ dlg.append_text("Launching StarNet...\n")
666
+
695
667
  thr = _ProcThread(command, cwd=starnet_dir)
696
- thr.output_signal.connect(dlg.append_text)
697
668
 
698
- # Capture everything we need in the closure for finish handler
699
- thr.finished_signal.connect(
700
- lambda rc, ds=did_stretch: _on_starnet_finished(
701
- main, doc, rc, dlg, input_image_path, output_image_path, ds
669
+ # ---- Output parsing (stages + percent finished + tile count) ----
670
+ _re_pct = re.compile(r"^\s*(\d{1,3})%\s+finished\s*$")
671
+ _re_tiles = re.compile(r"Total number of tiles:\s*(\d+)\s*$")
672
+
673
+ tile_total = {"n": 0}
674
+ last_pct = {"v": -1}
675
+
676
+ def _stage_from_line(low: str) -> str | None:
677
+ if "reading input image" in low:
678
+ return "Reading input image…"
679
+ if ("bits per sample" in low) or ("samples per pixel" in low) or ("height:" in low) or ("width:" in low):
680
+ return "Inspecting input…"
681
+ if "restoring neural network checkpoint" in low:
682
+ return "Loading model checkpoint…"
683
+ if "created device" in low and "gpu" in low:
684
+ return "Initializing GPU…"
685
+ if "loaded cudnn version" in low:
686
+ return "Initializing cuDNN…"
687
+ if "total number of tiles" in low:
688
+ return "Tiling image…"
689
+ if "% finished" in low:
690
+ return "Processing tiles…"
691
+ if "writing" in low or "saving" in low:
692
+ return "Writing output…"
693
+ return None
694
+
695
+ def _is_noise(low: str) -> bool:
696
+ # Keep progress + stage parsing, but optionally suppress spam in the log box.
697
+ # You can loosen/tighten this anytime.
698
+ return (
699
+ (low.startswith("202") and "tensorflow/" in low) or
700
+ ("cpu_feature_guard" in low) or
701
+ ("mlir" in low) or
702
+ ("ptxas" in low) or
703
+ ("bfc_allocator" in low) or
704
+ ("garbage collection" in low)
702
705
  )
703
- )
706
+
707
+ def _on_out(line: str):
708
+ low = line.lower()
709
+
710
+ # stage updates
711
+ st = _stage_from_line(low)
712
+ if st:
713
+ try:
714
+ dlg.lbl_stage.setText(st)
715
+ except Exception:
716
+ pass
717
+
718
+ # tile total
719
+ m = _re_tiles.search(line)
720
+ if m:
721
+ try:
722
+ tile_total["n"] = int(m.group(1))
723
+ except Exception:
724
+ tile_total["n"] = 0
725
+
726
+ # percent updates (throttled)
727
+ m = _re_pct.match(line)
728
+ if m:
729
+ try:
730
+ pct = int(m.group(1))
731
+ pct = max(0, min(100, pct))
732
+ if pct != last_pct["v"]:
733
+ last_pct["v"] = pct
734
+ if tile_total["n"] > 0:
735
+ done = int(round(tile_total["n"] * (pct / 100.0)))
736
+ dlg.set_progress(done, tile_total["n"], "Processing tiles…")
737
+ else:
738
+ dlg.set_progress(pct, 100, "Processing…")
739
+ except Exception:
740
+ pass
741
+
742
+ # append (optionally suppress TF spam)
743
+ if not _is_noise(low):
744
+ dlg.append_text(line)
745
+
746
+ thr.output_signal.connect(_on_out)
747
+
748
+ # finished -> apply + cleanup
749
+ def _on_finish(rc: int):
750
+ try:
751
+ # snap to 100% for UX (even if StarNet ended abruptly, it will be overwritten by error handling)
752
+ dlg.set_progress(100, 100, "StarNet finished. Loading output…")
753
+ except Exception:
754
+ pass
755
+ _on_starnet_finished(main, doc, rc, dlg, input_image_path, output_image_path, did_stretch)
756
+
757
+ thr.finished_signal.connect(_on_finish)
758
+
759
+ # cancel kills subprocess
704
760
  dlg.cancel_button.clicked.connect(thr.cancel)
705
761
 
706
- dlg.show()
707
762
  thr.start()
708
763
  dlg.exec()
709
764
 
710
-
711
765
  def _on_starnet_finished(main, doc, return_code, dialog, input_path, output_path, did_stretch):
712
766
  import os
713
767
  import numpy as np
714
768
  from PyQt6.QtWidgets import QMessageBox
715
769
  from setiastro.saspro.imageops.stretch import stretch_mono_image # used for statistical inverse
716
-
770
+ try:
771
+ dialog.pbar.setRange(0, 100)
772
+ dialog.set_progress(100, 100, "StarNet finished. Loading output…")
773
+ except Exception:
774
+ pass
717
775
  def _first_nonzero_bp_per_channel(img3: np.ndarray) -> np.ndarray:
718
776
  bps = np.zeros(3, dtype=np.float32)
719
777
  for c in range(3):
@@ -917,48 +975,28 @@ def _on_starnet_finished(main, doc, return_code, dialog, input_path, output_path
917
975
  # CosmicClarityDarkStar
918
976
  # ------------------------------------------------------------
919
977
  def _run_darkstar(main, doc):
920
- exe, base = _resolve_darkstar_exe(main)
921
- if not exe or not base:
922
- QMessageBox.critical(main, "Cosmic Clarity Folder Error",
923
- "Cosmic Clarity Dark Star executable not set.")
924
- return
925
-
926
- # --- Input/output folders per SASv2 ---
927
- input_dir = os.path.join(base, "input")
928
- output_dir = os.path.join(base, "output")
929
- os.makedirs(input_dir, exist_ok=True)
930
- os.makedirs(output_dir, exist_ok=True)
931
- _purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
978
+ import numpy as np
979
+ from PyQt6.QtWidgets import QMessageBox
932
980
 
933
- # --- Config dialog (same as before) ---
981
+ # --- Config dialog (keep as-is) ---
934
982
  cfg = DarkStarConfigDialog(main)
935
983
  if not cfg.exec():
936
984
  return
937
- params = cfg.get_values()
938
- disable_gpu = params["disable_gpu"]
939
- mode = params["mode"] # "unscreen" or "additive"
940
- show_extracted_stars = params["show_extracted_stars"]
941
- stride = params["stride"] # 64..1024, default 512
985
+ v = cfg.get_values()
942
986
 
943
- # 🔹 Ask if image is linear (so we know whether to MTF-prestretch)
944
- reply = QMessageBox.question(
945
- main, "Image Linearity", "Is the current image linear?",
946
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
947
- QMessageBox.StandardButton.Yes
948
- )
949
- is_linear = (reply == QMessageBox.StandardButton.Yes)
950
- did_prestretch = is_linear
987
+ disable_gpu = bool(v["disable_gpu"])
988
+ mode = str(v["mode"]) # "unscreen" | "additive"
989
+ show_extracted_stars = bool(v["show_extracted_stars"])
990
+ stride = int(v["stride"]) # chunk size
951
991
 
952
- # 🔹 Stash parameters for replay-last
992
+ # 🔹 Stash parameters for replay-last (same structure as you had)
953
993
  try:
954
994
  main._last_remove_stars_params = {
955
- "engine": "CosmicClarityDarkStar",
995
+ "engine": "DarkStar",
956
996
  "disable_gpu": bool(disable_gpu),
957
997
  "mode": mode,
958
998
  "show_extracted_stars": bool(show_extracted_stars),
959
999
  "stride": int(stride),
960
- "is_linear": bool(is_linear),
961
- "did_prestretch": bool(did_prestretch),
962
1000
  "label": "Remove Stars (DarkStar)",
963
1001
  }
964
1002
  except Exception:
@@ -974,344 +1012,155 @@ def _run_darkstar(main, doc):
974
1012
  "mode": mode,
975
1013
  "show_extracted_stars": bool(show_extracted_stars),
976
1014
  "stride": int(stride),
977
- "is_linear": bool(is_linear),
978
- "did_prestretch": bool(did_prestretch),
979
1015
  },
980
1016
  }
981
1017
  if hasattr(main, "_log"):
982
1018
  main._log(
983
1019
  "[Replay] Recorded remove_stars (DarkStar, "
984
- f"mode={mode}, stride={int(stride)}, "
1020
+ f"mode={mode}, chunk={int(stride)}, "
985
1021
  f"gpu={'off' if disable_gpu else 'on'}, "
986
- f"stars={'on' if show_extracted_stars else 'off'}, "
987
- f"linear={'yes' if is_linear else 'no'})"
1022
+ f"stars={'on' if show_extracted_stars else 'off'})"
988
1023
  )
989
1024
  except Exception:
990
1025
  pass
991
1026
 
992
- # --- Build processing image (RGB float32, normalized) ---
993
- # DarkStar needs RGB, but we can delay expansion until save
1027
+ # --- Build input image for engine: float32 [0..1], HxWx3/1/mono ok ---
994
1028
  src = np.asarray(doc.image)
995
- if src.ndim == 3 and src.shape[2] == 1:
996
- processing_image = src[..., 0]
997
- else:
998
- processing_image = src
1029
+ orig_was_mono = (src.ndim == 2) or (src.ndim == 3 and src.shape[2] == 1)
999
1030
 
1000
- processing_image = np.nan_to_num(
1001
- processing_image.astype(np.float32, copy=False),
1002
- nan=0.0, posinf=0.0, neginf=0.0
1003
- )
1031
+ x = np.nan_to_num(src.astype(np.float32, copy=False), nan=0.0, posinf=0.0, neginf=0.0)
1004
1032
 
1005
- scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
1033
+ # If your doc domain can exceed 1.0, normalize to [0..1] for the engine.
1034
+ # (Matches what you were already doing for external DarkStar.)
1035
+ scale_factor = float(np.max(x)) if x.size else 1.0
1006
1036
  if scale_factor > 1.0:
1007
- processing_norm = processing_image / scale_factor
1008
- else:
1009
- processing_norm = processing_image
1010
- processing_norm = np.clip(processing_norm, 0.0, 1.0)
1011
-
1012
- # --- Optional Siril-style MTF pre-stretch for linear data ---
1013
- img_for_darkstar = processing_norm
1014
- if is_linear:
1015
- try:
1016
- mtf_params = _mtf_params_unlinked(
1017
- processing_norm,
1018
- shadows_clipping=-2.8,
1019
- targetbg=0.25
1020
- )
1021
- img_for_darkstar = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
1022
-
1023
- # 🔐 Stash EXACT params for inverse step later
1024
- setattr(main, "_darkstar_mtf_meta", {
1025
- "s": np.asarray(mtf_params["s"], dtype=np.float32),
1026
- "m": np.asarray(mtf_params["m"], dtype=np.float32),
1027
- "h": np.asarray(mtf_params["h"], dtype=np.float32),
1028
- "scale": float(scale_factor),
1029
- })
1030
- if hasattr(main, "_log"):
1031
- main._log("[DarkStar] Applying Siril-style MTF pre-stretch for linear image.")
1032
- except Exception as e:
1033
- # If anything goes wrong, fall back to un-stretched normalized image
1034
- img_for_darkstar = processing_norm
1035
- try:
1036
- if hasattr(main, "_darkstar_mtf_meta"):
1037
- delattr(main, "_darkstar_mtf_meta")
1038
- except Exception:
1039
- pass
1040
- if hasattr(main, "_log"):
1041
- main._log(f"[DarkStar] MTF pre-stretch failed, using normalized image only: {e}")
1042
- else:
1043
- # Non-linear: don't store any pre-stretch meta
1044
- try:
1045
- if hasattr(main, "_darkstar_mtf_meta"):
1046
- delattr(main, "_darkstar_mtf_meta")
1047
- except Exception:
1048
- pass
1049
-
1050
- # --- Save pre-stretched image as 32-bit float TIFF for DarkStar ---
1051
- in_path = os.path.join(input_dir, "imagetoremovestars.tif")
1052
- try:
1053
- # Check if we need to expand on-the-fly for DarkStar (it expects RGB input)
1054
- # If img_for_darkstar is mono, save_image might save mono.
1055
- # "is_mono=False" flag to save_image hints we want RGB.
1056
- # If the array is 2D, save_image might still save mono unless we feed it 3D.
1057
- # For safety with DarkStar, we create the 3D view now if needed.
1058
-
1059
- to_save = img_for_darkstar
1060
- if to_save.ndim == 2:
1061
- to_save = np.stack([to_save]*3, axis=-1)
1062
- elif to_save.ndim == 3 and to_save.shape[2] == 1:
1063
- to_save = np.repeat(to_save, 3, axis=2)
1064
-
1065
- save_image(
1066
- to_save,
1067
- in_path,
1068
- original_format="tif",
1069
- bit_depth="32-bit floating point",
1070
- original_header=None,
1071
- is_mono=False, # we always send RGB to DarkStar
1072
- image_meta=None,
1073
- file_meta=None
1074
- )
1075
- except Exception as e:
1076
- QMessageBox.critical(main, "Cosmic Clarity", f"Failed to write input TIFF:\n{e}")
1077
- return
1078
-
1079
- # --- Build CLI exactly like SASv2 (using --chunk_size, not chunk_size) ---
1080
- args = []
1081
- if disable_gpu:
1082
- args.append("--disable_gpu")
1083
- args += ["--star_removal_mode", mode]
1084
- if show_extracted_stars:
1085
- args.append("--show_extracted_stars")
1086
- args += ["--chunk_size", str(stride)]
1087
-
1088
- command = [exe] + args
1089
-
1090
- dlg = _ProcDialog(main, title="CosmicClarityDarkStar Progress")
1091
- thr = _ProcThread(command, cwd=output_dir)
1092
- thr.output_signal.connect(dlg.append_text)
1093
- thr.finished_signal.connect(
1094
- lambda rc, base=base, ds=did_prestretch: _on_darkstar_finished(
1095
- main, doc, rc, dlg, in_path, output_dir, base, ds
1096
- )
1037
+ x = x / scale_factor
1038
+ x = np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
1039
+
1040
+ params = DarkStarParams(
1041
+ use_gpu=(not disable_gpu),
1042
+ chunk_size=int(stride),
1043
+ overlap_frac=0.125,
1044
+ mode=mode,
1045
+ output_stars_only=show_extracted_stars,
1097
1046
  )
1098
- dlg.cancel_button.clicked.connect(thr.cancel)
1099
-
1100
- dlg.show()
1101
- thr.start()
1102
- dlg.exec()
1103
1047
 
1048
+ dlg = _ProcDialog(main, title="Dark Star Progress")
1049
+ dlg.append_text("Starting Dark Star (engine)…\n")
1104
1050
 
1051
+ thr = _DarkStarThread(x, params, parent=dlg)
1105
1052
 
1053
+ def _on_prog(done, total, stage):
1054
+ dlg.set_progress(done, total, stage)
1106
1055
 
1107
- def _resolve_darkstar_exe(main):
1108
- """
1109
- Return (exe_path, base_folder) or (None, None) on cancel/error.
1110
- Accepts either a folder (stored) or a direct executable path.
1111
- Saves the folder back to QSettings under 'paths/cosmic_clarity'.
1112
- """
1113
- settings = getattr(main, "settings", None)
1114
- raw = _get_setting_any(settings, ("paths/cosmic_clarity", "cosmic_clarity_folder"), "")
1115
-
1116
- def _platform_exe_name():
1117
- return "setiastrocosmicclarity_darkstar.exe" if platform.system() == "Windows" \
1118
- else "setiastrocosmicclarity_darkstar"
1119
-
1120
- exe_name = _platform_exe_name()
1121
-
1122
- exe_path = None
1123
- base_folder = None
1124
-
1125
- if raw:
1126
- if os.path.isfile(raw):
1127
- # user stored the executable path directly
1128
- exe_path = raw
1129
- base_folder = os.path.dirname(raw)
1130
- elif os.path.isdir(raw):
1131
- # user stored the parent folder
1132
- base_folder = raw
1133
- exe_path = os.path.join(base_folder, exe_name)
1134
-
1135
- # if missing or invalid, let user pick the executable directly
1136
- if not exe_path or not os.path.exists(exe_path):
1137
- picked, _ = QFileDialog.getOpenFileName(main, "Select CosmicClarityDarkStar Executable", "", "Executable Files (*)")
1138
- if not picked:
1139
- return None, None
1140
- exe_path = picked
1141
- base_folder = os.path.dirname(picked)
1142
-
1143
- # ensure exec bit on POSIX
1144
- if platform.system() in ("Darwin", "Linux"):
1145
- _ensure_exec_bit(exe_path)
1146
-
1147
- # persist folder (not the exe) to the canonical key
1148
- if settings:
1149
- settings.setValue("paths/cosmic_clarity", base_folder)
1150
- settings.sync()
1151
-
1152
- return exe_path, base_folder
1153
-
1154
-
1155
- def _on_darkstar_finished(main, doc, return_code, dialog, in_path, output_dir, base_folder, did_prestretch):
1156
- dialog.append_text(f"\nProcess finished with return code {return_code}.\n")
1157
- if return_code != 0:
1158
- QMessageBox.critical(main, "CosmicClarityDarkStar Error",
1159
- f"CosmicClarityDarkStar failed with return code {return_code}.")
1160
- _safe_rm(in_path); dialog.close(); return
1161
-
1162
- starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
1163
- if not os.path.exists(starless_path):
1164
- QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Starless image was not created.")
1165
- _safe_rm(in_path); dialog.close(); return
1166
-
1167
- dialog.append_text(f"Loading starless image from {starless_path}...\n")
1168
- starless, _, _, _ = load_image(starless_path)
1169
- if starless is None:
1170
- QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Failed to load starless image.")
1171
- _safe_rm(in_path); dialog.close(); return
1172
-
1173
- if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
1174
- starless_rgb = np.stack([starless] * 3, axis=-1)
1175
- else:
1176
- starless_rgb = starless
1177
- starless_rgb = starless_rgb.astype(np.float32, copy=False)
1178
-
1179
- src = np.asarray(doc.image)
1180
- if src.ndim == 2:
1181
- original_rgb = np.stack([src] * 3, axis=-1)
1182
- orig_was_mono = True
1183
- elif src.ndim == 3 and src.shape[2] == 1:
1184
- original_rgb = np.repeat(src, 3, axis=2)
1185
- orig_was_mono = True
1186
- else:
1187
- original_rgb = src
1188
- orig_was_mono = False
1189
- original_rgb = original_rgb.astype(np.float32, copy=False)
1056
+ def _on_done(starless, stars_only, was_mono, err):
1057
+ if err:
1058
+ QMessageBox.critical(main, "Dark Star Error", f"Dark Star failed:\n{err}")
1059
+ dlg.close()
1060
+ return
1190
1061
 
1191
- # --- Undo the MTF pre-stretch (if we did one) ---
1192
- if did_prestretch:
1193
- meta = getattr(main, "_darkstar_mtf_meta", None)
1194
- if isinstance(meta, dict):
1195
- dialog.append_text("Unstretching starless result (DarkStar MTF inverse)...\n")
1062
+ # Engine returns float32 [0..1]; if we normalized >1, restore scale if you want.
1063
+ # BUT you later clip to [0..1] for doc anyway, so this is mostly for consistency.
1064
+ if scale_factor > 1.0:
1196
1065
  try:
1197
- s_vec = np.asarray(meta.get("s"), dtype=np.float32)
1198
- m_vec = np.asarray(meta.get("m"), dtype=np.float32)
1199
- h_vec = np.asarray(meta.get("h"), dtype=np.float32)
1200
- scale = float(meta.get("scale", 1.0))
1066
+ starless = starless * scale_factor
1067
+ if stars_only is not None:
1068
+ stars_only = stars_only * scale_factor
1069
+ except Exception:
1070
+ pass
1201
1071
 
1202
- p = {"s": s_vec, "m": m_vec, "h": h_vec}
1203
- inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
1072
+ # Original as RGB for blending math
1073
+ orig = np.asarray(doc.image).astype(np.float32, copy=False)
1074
+ if orig.ndim == 2:
1075
+ original_rgb = np.stack([orig]*3, axis=-1)
1076
+ elif orig.ndim == 3 and orig.shape[2] == 1:
1077
+ original_rgb = np.repeat(orig, 3, axis=2)
1078
+ else:
1079
+ original_rgb = orig
1204
1080
 
1205
- if scale > 1.0:
1206
- inv *= scale
1081
+ # Starless as RGB for blending math
1082
+ if starless.ndim == 2:
1083
+ starless_rgb = np.stack([starless]*3, axis=-1)
1084
+ elif starless.ndim == 3 and starless.shape[2] == 1:
1085
+ starless_rgb = np.repeat(starless, 3, axis=2)
1086
+ else:
1087
+ starless_rgb = starless
1207
1088
 
1208
- starless_rgb = np.clip(inv, 0.0, 1.0)
1209
- except Exception as e:
1210
- dialog.append_text(f"⚠️ DarkStar MTF inverse failed: {e}\n")
1089
+ starless_rgb = starless_rgb.astype(np.float32, copy=False)
1211
1090
 
1212
- # Clean up pre-stretch meta so it can't leak into another op
1213
- try:
1214
- if hasattr(main, "_darkstar_mtf_meta"):
1215
- delattr(main, "_darkstar_mtf_meta")
1216
- except Exception:
1217
- pass
1091
+ # ---- Optional stars-only push ----
1092
+ if show_extracted_stars:
1093
+ if stars_only is None:
1094
+ # Safety fallback if someone changes engine params later
1095
+ stars_only_rgb = np.clip(original_rgb - starless_rgb, 0.0, 1.0).astype(np.float32, copy=False)
1096
+ else:
1097
+ if stars_only.ndim == 2:
1098
+ stars_only_rgb = np.stack([stars_only]*3, axis=-1)
1099
+ elif stars_only.ndim == 3 and stars_only.shape[2] == 1:
1100
+ stars_only_rgb = np.repeat(stars_only, 3, axis=2)
1101
+ else:
1102
+ stars_only_rgb = stars_only.astype(np.float32, copy=False)
1218
1103
 
1219
- # --- stars-only optional push (as before) ---
1220
- stars_path = os.path.join(output_dir, "imagetoremovestars_stars_only.tif")
1221
- if os.path.exists(stars_path):
1222
- dialog.append_text(f"Loading stars-only image from {stars_path}...\n")
1223
- stars_only, _, _, _ = load_image(stars_path)
1224
- if stars_only is not None:
1225
- if stars_only.ndim == 2 or (stars_only.ndim == 3 and stars_only.shape[2] == 1):
1226
- stars_only = np.stack([stars_only] * 3, axis=-1)
1227
- stars_only = stars_only.astype(np.float32, copy=False)
1228
- m3 = _active_mask3_from_doc(doc, stars_only.shape[1], stars_only.shape[0])
1104
+ m3 = _active_mask3_from_doc(doc, stars_only_rgb.shape[1], stars_only_rgb.shape[0])
1229
1105
  if m3 is not None:
1230
- stars_only *= m3
1231
- dialog.append_text("✅ Applied active mask to stars-only image.\n")
1232
- else:
1233
- dialog.append_text("ℹ️ Mask not active for stars-only; skipping.\n")
1106
+ stars_only_rgb *= m3
1107
+ dlg.append_text("✅ Applied active mask to stars-only image.\n")
1234
1108
 
1235
- # If the original doc was mono, collapse stars-only back to single channel
1236
1109
  if orig_was_mono:
1237
- stars_to_push = stars_only.mean(axis=2).astype(np.float32, copy=False)
1110
+ stars_to_push = stars_only_rgb.mean(axis=2).astype(np.float32, copy=False)
1238
1111
  else:
1239
- stars_to_push = stars_only
1112
+ stars_to_push = stars_only_rgb
1240
1113
 
1241
1114
  _push_as_new_doc(main, doc, stars_to_push, title_suffix="_stars", source="Stars-Only (DarkStar)")
1242
- else:
1243
- dialog.append_text("Failed to load stars-only image.\n")
1244
- else:
1245
- dialog.append_text("No stars-only image generated.\n")
1246
-
1247
- # --- Mask-blend starless → overwrite current doc (in original domain) ---
1248
- dialog.append_text("Mask-blending starless image before update...\n")
1249
- final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
1250
-
1251
- # If the original doc was mono, collapse back to single-channel
1252
- if orig_was_mono:
1253
- final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
1254
- else:
1255
- final_to_apply = final_starless.astype(np.float32, copy=False)
1115
+ dlg.append_text("Stars-only image pushed.\n")
1256
1116
 
1257
- try:
1258
- meta = {
1259
- "step_name": "Stars Removed",
1260
- "bit_depth": "32-bit floating point",
1261
- "is_mono": bool(orig_was_mono),
1262
- }
1117
+ # ---- Mask-blend starless into current doc ----
1118
+ final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
1263
1119
 
1264
- # 🔹 Attach replay-last metadata
1265
- rp = getattr(main, "_last_remove_stars_params", None)
1266
- if isinstance(rp, dict):
1267
- replay_params = dict(rp)
1120
+ if orig_was_mono:
1121
+ final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
1268
1122
  else:
1269
- replay_params = {
1270
- "engine": "CosmicClarityDarkStar",
1271
- "label": "Remove Stars (DarkStar)",
1272
- }
1123
+ final_to_apply = final_starless.astype(np.float32, copy=False)
1273
1124
 
1274
- replay_params.setdefault("engine", "CosmicClarityDarkStar")
1275
- replay_params.setdefault("label", "Remove Stars (DarkStar)")
1125
+ # Clip to [0..1] because that’s what your pipeline expects almost everywhere
1126
+ final_to_apply = np.clip(final_to_apply, 0.0, 1.0).astype(np.float32, copy=False)
1276
1127
 
1277
- meta["replay_last"] = {
1278
- "op": "remove_stars",
1279
- "params": replay_params,
1280
- }
1281
-
1282
- # Clean up stash
1283
1128
  try:
1284
- if hasattr(main, "_last_remove_stars_params"):
1285
- delattr(main, "_last_remove_stars_params")
1286
- except Exception:
1287
- pass
1129
+ meta = {
1130
+ "step_name": "Stars Removed",
1131
+ "bit_depth": "32-bit floating point",
1132
+ "is_mono": bool(orig_was_mono),
1133
+ }
1288
1134
 
1289
- doc.apply_edit(
1290
- final_to_apply,
1291
- metadata=meta,
1292
- step_name="Stars Removed"
1293
- )
1294
- if hasattr(main, "_log"):
1295
- main._log("Stars Removed (DarkStar)")
1296
- except Exception as e:
1297
- QMessageBox.critical(main, "CosmicClarityDarkStar", f"Failed to apply result:\n{e}")
1135
+ rp = getattr(main, "_last_remove_stars_params", None)
1136
+ replay_params = dict(rp) if isinstance(rp, dict) else {"engine": "DarkStar", "label": "Remove Stars (DarkStar)"}
1137
+ replay_params.setdefault("engine", "DarkStar")
1138
+ replay_params.setdefault("label", "Remove Stars (DarkStar)")
1298
1139
 
1299
- # --- cleanup ---
1300
- try:
1301
- _safe_rm(in_path)
1302
- _safe_rm(starless_path)
1303
- _safe_rm(os.path.join(output_dir, "imagetoremovestars_stars_only.tif"))
1140
+ meta["replay_last"] = {"op": "remove_stars", "params": replay_params}
1304
1141
 
1305
- # 🔸 Final sweep: nuke any imagetoremovestars* leftovers in both dirs
1306
- base_folder = os.path.dirname(output_dir) # <-- derive CC base from output_dir
1307
- _purge_darkstar_io(base_folder, prefix="imagetoremovestars", clear_input=True, clear_output=True)
1142
+ try:
1143
+ if hasattr(main, "_last_remove_stars_params"):
1144
+ delattr(main, "_last_remove_stars_params")
1145
+ except Exception:
1146
+ pass
1308
1147
 
1309
- dialog.append_text("Temporary files cleaned up.\n")
1310
- except Exception as e:
1311
- dialog.append_text(f"Cleanup error: {e}\n")
1148
+ doc.apply_edit(final_to_apply, metadata=meta, step_name="Stars Removed")
1149
+ if hasattr(main, "_log"):
1150
+ main._log("Stars Removed (DarkStar)")
1151
+ except Exception as e:
1152
+ QMessageBox.critical(main, "Dark Star Error", f"Failed to apply result:\n{e}")
1312
1153
 
1313
- dialog.close()
1154
+ dlg.append_text("Done.\n")
1155
+ dlg.close()
1156
+
1157
+ thr.progress_signal.connect(_on_prog)
1158
+ thr.finished_signal.connect(_on_done)
1314
1159
 
1160
+ dlg.cancel_button.clicked.connect(lambda: dlg.append_text("Cancel not supported for in-process engine.\n"))
1161
+ dlg.show()
1162
+ thr.start()
1163
+ dlg.exec()
1315
1164
 
1316
1165
  # ------------------------------------------------------------
1317
1166
  # Mask helpers (doc-centric)
@@ -1453,32 +1302,6 @@ def _safe_rm(p):
1453
1302
  except Exception:
1454
1303
  pass
1455
1304
 
1456
- def _safe_rm_globs(patterns: list[str]):
1457
- for pat in patterns:
1458
- try:
1459
- for fp in glob.glob(pat):
1460
- _safe_rm(fp)
1461
- except Exception:
1462
- pass
1463
-
1464
- def _purge_darkstar_io(base_folder: str, *, prefix: str | None = None, clear_input=True, clear_output=True):
1465
- """Delete old image-like files from CC DarkStar input/output."""
1466
- try:
1467
- inp = os.path.join(base_folder, "input")
1468
- out = os.path.join(base_folder, "output")
1469
- if clear_input and os.path.isdir(inp):
1470
- for fn in os.listdir(inp):
1471
- fp = os.path.join(inp, fn)
1472
- if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
1473
- _safe_rm(fp)
1474
- if clear_output and os.path.isdir(out):
1475
- for fn in os.listdir(out):
1476
- fp = os.path.join(out, fn)
1477
- if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
1478
- _safe_rm(fp)
1479
- except Exception:
1480
- pass
1481
-
1482
1305
 
1483
1306
  # ------------------------------------------------------------
1484
1307
  # Proc runner & dialog (merged stdout/stderr)
@@ -1530,15 +1353,34 @@ class _ProcThread(QThread):
1530
1353
  self.process = None
1531
1354
  self.finished_signal.emit(rc)
1532
1355
 
1533
-
1534
1356
  class _ProcDialog(QDialog):
1535
1357
  def __init__(self, parent, title="Process"):
1536
1358
  super().__init__(parent)
1537
1359
  self.setWindowTitle(title)
1538
- self.setMinimumSize(600, 420)
1360
+ self.setMinimumSize(600, 460)
1361
+
1362
+ self._last_pct = -1 # for throttling UI updates
1363
+
1539
1364
  lay = QVBoxLayout(self)
1540
- self.text = QTextEdit(self); self.text.setReadOnly(True)
1541
- lay.addWidget(self.text)
1365
+
1366
+ # --- status line + progress bar ---
1367
+ self.lbl_stage = QLabel("", self)
1368
+ self.lbl_stage.setWordWrap(True)
1369
+ self.lbl_stage.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
1370
+ lay.addWidget(self.lbl_stage)
1371
+
1372
+ self.pbar = QProgressBar(self)
1373
+ self.pbar.setRange(0, 100)
1374
+ self.pbar.setValue(0)
1375
+ self.pbar.setTextVisible(True)
1376
+ lay.addWidget(self.pbar)
1377
+
1378
+ # --- log output ---
1379
+ self.text = QTextEdit(self)
1380
+ self.text.setReadOnly(True)
1381
+ lay.addWidget(self.text, 1)
1382
+
1383
+ # --- cancel ---
1542
1384
  self.cancel_button = QPushButton("Cancel", self)
1543
1385
  lay.addWidget(self.cancel_button)
1544
1386
 
@@ -1548,6 +1390,73 @@ class _ProcDialog(QDialog):
1548
1390
  except Exception:
1549
1391
  pass
1550
1392
 
1393
+ def set_progress(self, done: int, total: int, stage: str = ""):
1394
+ """
1395
+ Update stage label + progress bar.
1396
+ `total<=0` puts the bar into an indeterminate-ish state (0%).
1397
+ Throttles updates when percent hasn't changed.
1398
+ """
1399
+ try:
1400
+ if stage:
1401
+ self.lbl_stage.setText(stage)
1402
+
1403
+ if total and total > 0:
1404
+ pct = int(round(100.0 * float(done) / float(total)))
1405
+ pct = max(0, min(100, pct))
1406
+ else:
1407
+ pct = 0
1408
+
1409
+ # throttle: only repaint if percent changed or we're at end
1410
+ if pct != self._last_pct or done == total:
1411
+ self._last_pct = pct
1412
+ self.pbar.setValue(pct)
1413
+
1414
+ # keep the text helpful
1415
+ if total and total > 0:
1416
+ self.pbar.setFormat(f"{pct}% ({done}/{total})")
1417
+ else:
1418
+ self.pbar.setFormat(f"{pct}%")
1419
+ except Exception:
1420
+ pass
1421
+
1422
+ def reset_progress(self, stage: str = ""):
1423
+ self._last_pct = -1
1424
+ if stage:
1425
+ try:
1426
+ self.lbl_stage.setText(stage)
1427
+ except Exception:
1428
+ pass
1429
+ try:
1430
+ self.pbar.setValue(0)
1431
+ self.pbar.setFormat("0%")
1432
+ except Exception:
1433
+ pass
1434
+
1435
+ class _DarkStarThread(QThread):
1436
+ progress_signal = pyqtSignal(int, int, str) # done, total, stage
1437
+ finished_signal = pyqtSignal(object, object, bool, str) # starless, stars_only, was_mono, errstr
1438
+
1439
+ def __init__(self, img_rgb01: np.ndarray, params: DarkStarParams, parent=None):
1440
+ super().__init__(parent)
1441
+ self._img = img_rgb01
1442
+ self._params = params
1443
+
1444
+ def run(self):
1445
+ try:
1446
+ def prog(done, total, stage):
1447
+ self.progress_signal.emit(int(done), int(total), str(stage))
1448
+
1449
+ # status_cb is optional; keep quiet or route to progress text if you want
1450
+ starless, stars_only, was_mono = darkstar_starremoval_rgb01(
1451
+ self._img,
1452
+ params=self._params,
1453
+ progress_cb=prog,
1454
+ status_cb=lambda s: None,
1455
+ )
1456
+ self.finished_signal.emit(starless, stars_only, bool(was_mono), "")
1457
+ except Exception as e:
1458
+ self.finished_signal.emit(None, None, False, str(e))
1459
+
1551
1460
 
1552
1461
  class DarkStarConfigDialog(QDialog):
1553
1462
  """
@@ -1597,3 +1506,76 @@ class DarkStarConfigDialog(QDialog):
1597
1506
  "show_extracted_stars": self.chk_show_stars.isChecked(),
1598
1507
  "stride": int(self.cmb_stride.currentData()),
1599
1508
  }
1509
+
1510
+
1511
+ def darkstar_starless_from_array(
1512
+ arr_rgb01: np.ndarray,
1513
+ *,
1514
+ use_gpu: bool = True,
1515
+ chunk_size: int = 512,
1516
+ overlap_frac: float = 0.125,
1517
+ mode: str = "unscreen", # "unscreen" | "additive"
1518
+ output_stars_only: bool = False,
1519
+ progress_cb=None, # (done:int, total:int, stage:str) -> None
1520
+ status_cb=None # (msg:str) -> None
1521
+ ) -> tuple[np.ndarray, np.ndarray | None, bool]:
1522
+ """
1523
+ Headless DarkStar:
1524
+ input: float32 [0..1], mono or RGB
1525
+ output: (starless, stars_only_or_None, was_mono)
1526
+ """
1527
+ x = np.asarray(arr_rgb01, dtype=np.float32)
1528
+
1529
+ was_mono = (x.ndim == 2) or (x.ndim == 3 and x.shape[2] == 1)
1530
+
1531
+ # Normalize shape to what engine expects (it can accept mono or rgb, but keep it consistent)
1532
+ if x.ndim == 3 and x.shape[2] == 1:
1533
+ x = x[..., 0] # collapse to (H,W) for mono
1534
+
1535
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
1536
+ x = np.clip(x, 0.0, 1.0)
1537
+
1538
+ params = DarkStarParams(
1539
+ use_gpu=bool(use_gpu),
1540
+ chunk_size=int(chunk_size),
1541
+ overlap_frac=float(overlap_frac),
1542
+ mode=str(mode),
1543
+ output_stars_only=bool(output_stars_only),
1544
+ )
1545
+
1546
+ def _prog(done, total, stage):
1547
+ if callable(progress_cb):
1548
+ try:
1549
+ progress_cb(int(done), int(total), str(stage))
1550
+ except Exception:
1551
+ pass
1552
+
1553
+ def _status(msg: str):
1554
+ if callable(status_cb):
1555
+ try:
1556
+ status_cb(str(msg))
1557
+ except Exception:
1558
+ pass
1559
+
1560
+ starless, stars_only, engine_was_mono = darkstar_starremoval_rgb01(
1561
+ x,
1562
+ params=params,
1563
+ progress_cb=_prog if callable(progress_cb) else None,
1564
+ status_cb=_status if callable(status_cb) else None,
1565
+ )
1566
+
1567
+ # Engine should return [0..1] float32, but enforce it anyway
1568
+ if starless is not None:
1569
+ starless = np.clip(np.asarray(starless, dtype=np.float32), 0.0, 1.0)
1570
+
1571
+ if stars_only is not None:
1572
+ stars_only = np.clip(np.asarray(stars_only, dtype=np.float32), 0.0, 1.0)
1573
+
1574
+ # If input was mono, guarantee mono out (some paths may hand back rgb)
1575
+ if was_mono and starless is not None and starless.ndim == 3:
1576
+ starless = starless.mean(axis=2).astype(np.float32, copy=False)
1577
+
1578
+ if was_mono and stars_only is not None and stars_only.ndim == 3:
1579
+ stars_only = stars_only.mean(axis=2).astype(np.float32, copy=False)
1580
+
1581
+ return starless, (stars_only if output_stars_only else None), bool(was_mono)