setiastrosuitepro 1.6.7__py3-none-any.whl → 1.6.10__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 (37) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/cosmic.svg +40 -0
  3. setiastro/images/cosmicsat.svg +24 -0
  4. setiastro/images/graxpert.svg +19 -0
  5. setiastro/images/linearfit.svg +32 -0
  6. setiastro/images/pixelmath.svg +42 -0
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/add_stars.py +29 -5
  9. setiastro/saspro/blink_comparator_pro.py +74 -24
  10. setiastro/saspro/cosmicclarity.py +125 -18
  11. setiastro/saspro/crop_dialog_pro.py +96 -2
  12. setiastro/saspro/curve_editor_pro.py +60 -39
  13. setiastro/saspro/frequency_separation.py +1159 -208
  14. setiastro/saspro/gui/main_window.py +131 -31
  15. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  16. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  17. setiastro/saspro/imageops/stretch.py +531 -62
  18. setiastro/saspro/layers.py +13 -9
  19. setiastro/saspro/layers_dock.py +183 -3
  20. setiastro/saspro/legacy/numba_utils.py +43 -0
  21. setiastro/saspro/live_stacking.py +158 -70
  22. setiastro/saspro/multiscale_decomp.py +47 -12
  23. setiastro/saspro/numba_utils.py +72 -2
  24. setiastro/saspro/ops/commands.py +18 -18
  25. setiastro/saspro/shortcuts.py +122 -12
  26. setiastro/saspro/signature_insert.py +688 -33
  27. setiastro/saspro/stacking_suite.py +523 -316
  28. setiastro/saspro/stat_stretch.py +688 -130
  29. setiastro/saspro/subwindow.py +302 -71
  30. setiastro/saspro/widgets/common_utilities.py +28 -21
  31. setiastro/saspro/widgets/resource_monitor.py +7 -7
  32. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
  33. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
  34. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  35. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  36. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  37. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -57,6 +57,120 @@ def _anchor_point(base_w: int, base_h: int, ins_w: int, ins_h: int,
57
57
  }
58
58
  return table.get(key, QPointF(right, bottom)) # default BR
59
59
 
60
+ # --------------------------- Header helpers (DocManager truth) ---------------------------
61
+
62
+ def _header_items(header_obj):
63
+ """
64
+ Yield (key, value) pairs from whatever DocManager stored as the header.
65
+ Supports:
66
+ - astropy Header-like (has .cards OR .keys/.get) without importing astropy
67
+ - dict-like (has .items)
68
+ - list/tuple of (k, v) pairs
69
+ """
70
+ if header_obj is None:
71
+ return []
72
+
73
+ # dict-like
74
+ items = getattr(header_obj, "items", None)
75
+ if callable(items):
76
+ try:
77
+ return list(header_obj.items())
78
+ except Exception:
79
+ pass
80
+
81
+ # astropy Header-like: prefer cards if present
82
+ cards = getattr(header_obj, "cards", None)
83
+ if cards is not None:
84
+ try:
85
+ out = []
86
+ for c in cards:
87
+ k = getattr(c, "keyword", None)
88
+ v = getattr(c, "value", None)
89
+ if k is not None:
90
+ out.append((k, v))
91
+ return out
92
+ except Exception:
93
+ pass
94
+
95
+ # Header-like: keys()+get()
96
+ keys = getattr(header_obj, "keys", None)
97
+ getv = getattr(header_obj, "get", None)
98
+ if callable(keys) and callable(getv):
99
+ try:
100
+ out = []
101
+ for k in header_obj.keys():
102
+ try:
103
+ out.append((k, header_obj.get(k)))
104
+ except Exception:
105
+ continue
106
+ return out
107
+ except Exception:
108
+ pass
109
+
110
+ # list of pairs
111
+ if isinstance(header_obj, (list, tuple)):
112
+ try:
113
+ out = []
114
+ for it in header_obj:
115
+ if isinstance(it, (list, tuple)) and len(it) >= 2:
116
+ out.append((it[0], it[1]))
117
+ return out
118
+ except Exception:
119
+ pass
120
+
121
+ return []
122
+
123
+
124
+ def _header_to_dict(header_obj) -> dict[str, str]:
125
+ """
126
+ Normalize any header-ish object into {KEY: VALUE_STR}.
127
+ DOES NOT read from disk. Uses what DocManager already has.
128
+ """
129
+ out: dict[str, str] = {}
130
+ for k, v in _header_items(header_obj):
131
+ if k is None:
132
+ continue
133
+ ks = str(k).strip()
134
+ if not ks:
135
+ continue
136
+
137
+ # Skip non-meaningful values
138
+ if v is None:
139
+ continue
140
+
141
+ vs = str(v).strip()
142
+ if not vs:
143
+ continue
144
+
145
+ out[ks] = vs
146
+ return out
147
+
148
+
149
+ def _get_docmanager_header_dict(doc) -> dict[str, str]:
150
+ """
151
+ Pull the *current* header already living in doc.metadata, as maintained by DocManager.
152
+ Priority should match your save logic expectations:
153
+ - wcs_header (if it's a real header object)
154
+ - fits_header
155
+ - original_header
156
+ - header
157
+ """
158
+ meta = getattr(doc, "metadata", None) or {}
159
+
160
+ # IMPORTANT: This order matches what you WANT for display:
161
+ # show WCS-enhanced header if present, otherwise fall back.
162
+ for key in ("wcs_header", "fits_header", "original_header", "header"):
163
+ hdr_obj = meta.get(key)
164
+ if hdr_obj is None:
165
+ continue
166
+
167
+ d = _header_to_dict(hdr_obj)
168
+ if d:
169
+ return d
170
+
171
+ return {}
172
+
173
+
60
174
  def apply_signature_preset_to_doc(doc, preset: dict) -> np.ndarray:
61
175
  """
62
176
  Headless apply of signature/insert using a preset.
@@ -328,29 +442,137 @@ class InsertView(QGraphicsView):
328
442
  self.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
329
443
  self.zoom_factor = 1.0 # logical reset
330
444
 
331
- # --- context menu to snap inserts ---
332
445
  def contextMenuEvent(self, e):
333
446
  scene_pos = self.mapToScene(e.pos())
334
447
  item = self.scene().itemAt(scene_pos, self.transform())
335
448
 
336
- # If user clicked the child rect, use the parent pixmap
449
+ if item is None:
450
+ return super().contextMenuEvent(e)
451
+
452
+ # 1) If user clicked the child rect (bounding box) -> use the parent pixmap insert
337
453
  if isinstance(item, QGraphicsRectItem) and item.parentItem() in self.owner.inserts:
338
454
  item = item.parentItem()
339
455
 
340
- if ((isinstance(item, QGraphicsPixmapItem) and item in self.owner.inserts) or
341
- isinstance(item, QGraphicsTextItem)):
342
- m = QMenu(self)
343
- pos = {
344
- "Top-Left":"top_left", "Top-Center":"top_center", "Top-Right":"top_right",
345
- "Middle-Left":"middle_left","Center":"center","Middle-Right":"middle_right",
346
- "Bottom-Left":"bottom_left","Bottom-Center":"bottom_center","Bottom-Right":"bottom_right"
347
- }
348
- for label, key in pos.items():
349
- m.addAction(label, lambda k=key, it=item: self.owner.send_insert_to_position(it, k))
350
- m.exec(e.globalPos())
351
- return
352
- else:
353
- super().contextMenuEvent(e)
456
+ # 2) If user clicked *inside* a TechCard (bg/border/text) -> walk up to TechCardItem
457
+ # (TechCardItem is a parent of its bg/border/text children)
458
+ p = item
459
+ while p is not None and not isinstance(p, TechCardItem):
460
+ p = p.parentItem()
461
+ if isinstance(p, TechCardItem):
462
+ item = p
463
+
464
+ # 3) Determine if this is a snap-eligible insert
465
+ is_pix_insert = isinstance(item, QGraphicsPixmapItem) and item in self.owner.inserts
466
+ is_text_insert = isinstance(item, QGraphicsTextItem) # includes OutlinedTextItem
467
+ is_tech_card = isinstance(item, TechCardItem)
468
+
469
+ if not (is_pix_insert or is_text_insert or is_tech_card):
470
+ return super().contextMenuEvent(e)
471
+
472
+ # 4) Build one menu
473
+ m = QMenu(self)
474
+ pos = {
475
+ "Top-Left": "top_left",
476
+ "Top-Center": "top_center",
477
+ "Top-Right": "top_right",
478
+ "Middle-Left": "middle_left",
479
+ "Center": "center",
480
+ "Middle-Right": "middle_right",
481
+ "Bottom-Left": "bottom_left",
482
+ "Bottom-Center": "bottom_center",
483
+ "Bottom-Right": "bottom_right",
484
+ }
485
+ for label, key in pos.items():
486
+ m.addAction(label, lambda k=key, it=item: self.owner.send_insert_to_position(it, k))
487
+
488
+ m.exec(e.globalPos())
489
+ e.accept()
490
+
491
+
492
+ class TechCardItem(QGraphicsItem):
493
+ def __init__(self, text_item: OutlinedTextItem):
494
+ super().__init__()
495
+ self.bg = QGraphicsRectItem(self)
496
+ self.border = QGraphicsRectItem(self)
497
+ self.text = text_item
498
+ self.text.setParentItem(self)
499
+
500
+ self.padding = 16
501
+ self.bg_color = QColor(0, 0, 0)
502
+ self.bg_opacity = 0.55
503
+
504
+ self.border_enabled = True
505
+ self.border_pen = QPen(QColor("white"), 2, Qt.PenStyle.SolidLine)
506
+ self.border_pen.setCosmetic(True)
507
+
508
+ # ✅ correct: QPen/QBrush objects
509
+ self.bg.setPen(QPen(Qt.PenStyle.NoPen))
510
+ self.bg.setBrush(QBrush(self.bg_color))
511
+ self.bg.setOpacity(self.bg_opacity)
512
+
513
+ self.border.setBrush(QBrush(Qt.BrushStyle.NoBrush))
514
+ self.border.setPen(self.border_pen)
515
+ self.border.setVisible(self.border_enabled)
516
+
517
+ self.setFlags(
518
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
519
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
520
+ QGraphicsItem.GraphicsItemFlag.ItemIsFocusable |
521
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
522
+ )
523
+ self.setZValue(1)
524
+ self._rebuild_geometry()
525
+
526
+
527
+ def boundingRect(self) -> QRectF:
528
+ # union of child rects (border covers bg; include text)
529
+ r = QRectF()
530
+ r = r.united(self.bg.rect())
531
+ r = r.united(self.border.rect())
532
+ r = r.united(self.text.mapRectToParent(self.text.boundingRect()))
533
+ return r
534
+
535
+ def paint(self, painter, option, widget=None):
536
+ # children paint themselves
537
+ pass
538
+
539
+ def set_padding(self, px: int):
540
+ self.padding = max(0, int(px))
541
+ self._rebuild_geometry()
542
+
543
+ def set_background(self, c: QColor, opacity: float):
544
+ self.bg_color = QColor(c)
545
+ self.bg_opacity = max(0.0, min(1.0, float(opacity)))
546
+ self.bg.setBrush(QBrush(self.bg_color))
547
+ self.bg.setOpacity(self.bg_opacity)
548
+
549
+ def set_border(self, enabled: bool, pen: QPen):
550
+ self.border_enabled = bool(enabled)
551
+ self.border_pen = QPen(pen)
552
+ self.border_pen.setCosmetic(True)
553
+ self.border.setVisible(self.border_enabled)
554
+ self.border.setPen(self.border_pen)
555
+
556
+ def set_text(self, s: str):
557
+ self.text.setPlainText(s)
558
+ self._rebuild_geometry()
559
+
560
+ def _rebuild_geometry(self):
561
+ self.prepareGeometryChange() # <-- move to top
562
+
563
+ self.text.setPos(QPointF(self.padding, self.padding))
564
+ tr = self.text.mapRectToParent(self.text.boundingRect())
565
+
566
+ w = tr.width() + 2 * self.padding
567
+ h = tr.height() + 2 * self.padding
568
+
569
+ rect = QRectF(0, 0, w, h)
570
+ self.bg.setRect(rect)
571
+ self.border.setRect(rect)
572
+
573
+ self.setTransformOriginPoint(rect.center())
574
+ self.update()
575
+
354
576
 
355
577
 
356
578
  # --------------------------- Main dialog ---------------------------
@@ -384,13 +606,24 @@ class SignatureInsertDialogPro(QDialog):
384
606
  self.bounding_boxes: list[QGraphicsRectItem] = []
385
607
  self.bounding_boxes_enabled = True
386
608
  self.bounding_box_pen = QPen(QColor("red"), 2, Qt.PenStyle.DashLine)
609
+ self.bounding_box_pen.setCosmetic(True)
387
610
  self.text_inserts: list[OutlinedTextItem] = []
388
611
  self.scene.selectionChanged.connect(self._on_selection_changed)
389
612
  # Handle sync timer (keeps the handle parked on the item corner)
390
613
  self._timer = QTimer(self); self._timer.timeout.connect(self._sync_handles); self._timer.start(16)
614
+ self.tech_card_item: TechCardItem | None = None
615
+ self.tech_card_text: OutlinedTextItem | None = None
616
+ self.tech_fields_order: list[str] = []
617
+ self.tech_fields_enabled: set[str] = set()
618
+ self.tech_bg_color = QColor(0, 0, 0)
619
+ self.tech_border_color = QColor("white")
620
+ self.tech_text_fill = QColor("white")
621
+ self.tech_text_outline = QColor("black")
391
622
 
392
623
  self._build_ui()
624
+
393
625
  self._update_base_image()
626
+ self._tech_init_catalog()
394
627
  self.resize(1000, 680)
395
628
 
396
629
  # -------- UI ----------
@@ -452,6 +685,81 @@ class SignatureInsertDialogPro(QDialog):
452
685
 
453
686
  col.addWidget(txt_grp)
454
687
 
688
+ # --- Tech Card ---------------------------------------------------------
689
+ tech_grp = QGroupBox("Technical Card")
690
+ tc = QGridLayout(tech_grp)
691
+
692
+ self.cb_tech = QCheckBox("Enable Tech Card")
693
+ self.cb_tech.stateChanged.connect(self._tech_toggle)
694
+
695
+ self.btn_tech_build = QPushButton("Build / Update")
696
+ self.btn_tech_build.clicked.connect(self._tech_rebuild)
697
+
698
+ self.btn_tech_reset = QPushButton("Reset")
699
+ self.btn_tech_reset.clicked.connect(self._tech_reset_defaults)
700
+
701
+ tc.addWidget(self.cb_tech, 0, 0, 1, 2)
702
+ tc.addWidget(self.btn_tech_build, 0, 2)
703
+ tc.addWidget(self.btn_tech_reset, 0, 3)
704
+
705
+ # field list (simple: checklist combo)
706
+ self.cmb_tech_field = QComboBox()
707
+ self.btn_tech_add_field = QPushButton("Add Field")
708
+ self.btn_tech_remove_field = QPushButton("Remove Field")
709
+ self.btn_tech_up = QPushButton("Up")
710
+ self.btn_tech_down = QPushButton("Down")
711
+
712
+ self.btn_tech_add_field.clicked.connect(self._tech_add_field)
713
+ self.btn_tech_remove_field.clicked.connect(self._tech_remove_field)
714
+ self.btn_tech_up.clicked.connect(lambda: self._tech_move_field(-1))
715
+ self.btn_tech_down.clicked.connect(lambda: self._tech_move_field(+1))
716
+
717
+ tc.addWidget(QLabel("Fields"), 1, 0)
718
+ tc.addWidget(self.cmb_tech_field, 1, 1, 1, 2)
719
+ tc.addWidget(self.btn_tech_add_field, 1, 3)
720
+
721
+ self.cmb_tech_order = QComboBox() # shows current ordered selection
722
+ tc.addWidget(QLabel("Order"), 2, 0)
723
+ tc.addWidget(self.cmb_tech_order, 2, 1, 1, 2)
724
+ btns = QHBoxLayout()
725
+ btns.addWidget(self.btn_tech_remove_field)
726
+ btns.addWidget(self.btn_tech_up)
727
+ btns.addWidget(self.btn_tech_down)
728
+ w_btns = QWidget(); w_btns.setLayout(btns)
729
+ tc.addWidget(w_btns, 2, 3)
730
+
731
+ self.cb_tech_hide_empty = QCheckBox("Hide empty fields")
732
+ self.cb_tech_hide_empty.setChecked(True)
733
+ self.cb_tech_hide_empty.stateChanged.connect(lambda: self._tech_rebuild(live=True))
734
+ tc.addWidget(self.cb_tech_hide_empty, 3, 0, 1, 2)
735
+
736
+ # style controls
737
+ self.sp_tech_padding = QSpinBox(); self.sp_tech_padding.setRange(0, 200); self.sp_tech_padding.setValue(16)
738
+ self.sp_tech_padding.valueChanged.connect(lambda: self._tech_rebuild(live=True))
739
+
740
+ self.sl_tech_bg_opacity = QSlider(Qt.Orientation.Horizontal); self.sl_tech_bg_opacity.setRange(0, 100); self.sl_tech_bg_opacity.setValue(55)
741
+ self.sl_tech_bg_opacity.valueChanged.connect(lambda: self._tech_rebuild(live=True))
742
+
743
+ self.cb_tech_border = QCheckBox("Border"); self.cb_tech_border.setChecked(True)
744
+ self.cb_tech_border.stateChanged.connect(lambda: self._tech_rebuild(live=True))
745
+
746
+ self.sp_tech_border_w = QSpinBox(); self.sp_tech_border_w.setRange(0, 30); self.sp_tech_border_w.setValue(2)
747
+ self.sp_tech_border_w.valueChanged.connect(lambda: self._tech_rebuild(live=True))
748
+
749
+ self.cmb_tech_border_style = QComboBox()
750
+ self.cmb_tech_border_style.addItems(["Solid","Dash","Dot","DashDot","DashDotDot"])
751
+ self.cmb_tech_border_style.currentIndexChanged.connect(lambda: self._tech_rebuild(live=True))
752
+
753
+ self.btn_tech_bg = QPushButton("BG Color…"); self.btn_tech_bg.clicked.connect(self._tech_pick_bg)
754
+ self.btn_tech_border_color = QPushButton("Border Color…"); self.btn_tech_border_color.clicked.connect(self._tech_pick_border)
755
+
756
+ tc.addWidget(QLabel("Padding"), 4, 0); tc.addWidget(self.sp_tech_padding, 4, 1)
757
+ tc.addWidget(QLabel("BG Opacity"), 5, 0); tc.addWidget(self.sl_tech_bg_opacity, 5, 1, 1, 3)
758
+ tc.addWidget(self.btn_tech_bg, 4, 2); tc.addWidget(self.btn_tech_border_color, 4, 3)
759
+ tc.addWidget(self.cb_tech_border, 6, 0); tc.addWidget(QLabel("Width"), 6, 1); tc.addWidget(self.sp_tech_border_w, 6, 2); tc.addWidget(self.cmb_tech_border_style, 6, 3)
760
+
761
+ col.addWidget(tech_grp)
762
+
455
763
 
456
764
  # Transform group
457
765
  grp = QGroupBox("Transform")
@@ -540,6 +848,279 @@ class SignatureInsertDialogPro(QDialog):
540
848
  root.addWidget(left, 0)
541
849
  root.addWidget(self.view, 1)
542
850
 
851
+ def _tech_active_doc(self):
852
+ dm = getattr(self, "doc_manager", None)
853
+ if dm is not None:
854
+ try:
855
+ d = dm.get_active_document()
856
+ if d is not None:
857
+ return d
858
+ except Exception:
859
+ pass
860
+ return getattr(self, "doc", None) or getattr(self, "_doc", None)
861
+
862
+ def _tech_pick_header_obj(self, doc):
863
+ meta = getattr(doc, "metadata", None) or {}
864
+ for key in ("wcs_header", "fits_header", "original_header", "header"):
865
+ h = meta.get(key)
866
+ if h is not None:
867
+ return h
868
+ return None
869
+
870
+ def _tech_header_to_dict(self, hdr_obj) -> dict[str, str]:
871
+ """
872
+ Convert whatever header object DocManager stored into {KEY: VALUE_STR}.
873
+ No disk IO. No astropy import.
874
+ """
875
+ if hdr_obj is None:
876
+ return {}
877
+
878
+ # dict-like
879
+ items = getattr(hdr_obj, "items", None)
880
+ if callable(items):
881
+ try:
882
+ return {str(k).strip(): str(v).strip() for k, v in hdr_obj.items()
883
+ if str(k).strip() and str(v).strip() and str(v).strip() != "None"}
884
+ except Exception:
885
+ pass
886
+
887
+ # astropy Header-like: cards iterable with .keyword/.value
888
+ cards = getattr(hdr_obj, "cards", None)
889
+ if cards is not None:
890
+ out = {}
891
+ try:
892
+ for c in cards:
893
+ k = getattr(c, "keyword", None)
894
+ v = getattr(c, "value", None)
895
+ if k is None or v is None:
896
+ continue
897
+ ks = str(k).strip()
898
+ vs = str(v).strip()
899
+ if ks and vs and vs != "None":
900
+ out[ks] = vs
901
+ if out:
902
+ return out
903
+ except Exception:
904
+ pass
905
+
906
+ # Header-like: keys()+get()
907
+ keys = getattr(hdr_obj, "keys", None)
908
+ getv = getattr(hdr_obj, "get", None)
909
+ if callable(keys) and callable(getv):
910
+ out = {}
911
+ try:
912
+ for k in hdr_obj.keys():
913
+ try:
914
+ v = hdr_obj.get(k)
915
+ except Exception:
916
+ continue
917
+ if v is None:
918
+ continue
919
+ ks = str(k).strip()
920
+ vs = str(v).strip()
921
+ if ks and vs and vs != "None":
922
+ out[ks] = vs
923
+ return out
924
+ except Exception:
925
+ pass
926
+
927
+ return {}
928
+
929
+
930
+ def _tech_init_catalog(self):
931
+ catalog = self._build_tech_field_catalog()
932
+ keys = list(catalog.keys())
933
+ self.cmb_tech_field.clear()
934
+ self.cmb_tech_field.addItems(keys)
935
+
936
+ # defaults: populate order + enabled if empty
937
+ if not self.tech_fields_order:
938
+ defaults = ["OBJECT","DATE-OBS","EXPTIME","FILTER","GAIN","OFFSET","INSTRUME","TELESCOP","FOCALLEN","XPIXSZ","BAYERPAT","Size","Bit Depth","Mono"]
939
+ self.tech_fields_order = [k for k in defaults if k in catalog]
940
+ self.tech_fields_enabled = set(self.tech_fields_order)
941
+
942
+ self._tech_refresh_order_combo()
943
+
944
+ def _tech_refresh_order_combo(self):
945
+ self.cmb_tech_order.blockSignals(True)
946
+ self.cmb_tech_order.clear()
947
+ self.cmb_tech_order.addItems(self.tech_fields_order)
948
+ self.cmb_tech_order.blockSignals(False)
949
+
950
+ def _tech_toggle(self, state):
951
+ enabled = bool(state)
952
+ if enabled:
953
+ self._tech_ensure_item()
954
+ self._tech_rebuild()
955
+ else:
956
+ self._tech_remove_item()
957
+
958
+ def _tech_ensure_item(self):
959
+ if self.tech_card_item is not None:
960
+ return
961
+
962
+ # Create a text item for the card (outlined)
963
+ f = self._current_qfont()
964
+ self.tech_card_text = OutlinedTextItem("", f, self.tech_text_fill, self.tech_text_outline, outline_w=float(self.outline_w.value()))
965
+ self.tech_card_text.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) # card text is not editable directly
966
+ self.tech_card_text.setZValue(1)
967
+
968
+ self.tech_card_item = TechCardItem(self.tech_card_text)
969
+ self.tech_card_item.setZValue(1)
970
+ self.scene.addItem(self.tech_card_item)
971
+
972
+ TransformHandle(self.tech_card_item, self.scene)
973
+
974
+ # drop near bottom-right by default using your snap
975
+ self.tech_card_item.setSelected(True)
976
+ self.send_insert_to_position(self.tech_card_item, "top left")
977
+
978
+ def _tech_remove_item(self):
979
+ if self.tech_card_item is None:
980
+ return
981
+ # Remove handle tied to tech card
982
+ for it in list(self.scene.items()):
983
+ if isinstance(it, TransformHandle) and getattr(it, "parent_item", None) is self.tech_card_item:
984
+ try: self.scene.removeItem(it)
985
+ except Exception: pass
986
+
987
+ try:
988
+ self.scene.removeItem(self.tech_card_item)
989
+ except Exception:
990
+ pass
991
+
992
+ self.tech_card_item = None
993
+ self.tech_card_text = None
994
+
995
+ def _tech_pick_bg(self):
996
+ c = QColorDialog.getColor(self.tech_bg_color, self, "Tech Card Background")
997
+ if c.isValid():
998
+ self.tech_bg_color = c
999
+ self._tech_rebuild(live=True)
1000
+
1001
+ def _tech_pick_border(self):
1002
+ c = QColorDialog.getColor(self.tech_border_color, self, "Tech Card Border")
1003
+ if c.isValid():
1004
+ self.tech_border_color = c
1005
+ self._tech_rebuild(live=True)
1006
+
1007
+ def _tech_add_field(self):
1008
+ k = self.cmb_tech_field.currentText().strip()
1009
+ if not k:
1010
+ return
1011
+ if k not in self.tech_fields_order:
1012
+ self.tech_fields_order.append(k)
1013
+ self.tech_fields_enabled.add(k)
1014
+ self._tech_refresh_order_combo()
1015
+ self._tech_rebuild(live=True)
1016
+
1017
+ def _tech_remove_field(self):
1018
+ k = self.cmb_tech_order.currentText().strip()
1019
+ if not k:
1020
+ return
1021
+ if k in self.tech_fields_order:
1022
+ self.tech_fields_order.remove(k)
1023
+ self.tech_fields_enabled.discard(k)
1024
+ self._tech_refresh_order_combo()
1025
+ self._tech_rebuild(live=True)
1026
+
1027
+ def _tech_move_field(self, delta: int):
1028
+ k = self.cmb_tech_order.currentText().strip()
1029
+ if not k or k not in self.tech_fields_order:
1030
+ return
1031
+ i = self.tech_fields_order.index(k)
1032
+ j = max(0, min(len(self.tech_fields_order) - 1, i + int(delta)))
1033
+ if i == j:
1034
+ return
1035
+ self.tech_fields_order.insert(j, self.tech_fields_order.pop(i))
1036
+ self._tech_refresh_order_combo()
1037
+ self.cmb_tech_order.setCurrentText(k)
1038
+ self._tech_rebuild(live=True)
1039
+
1040
+ def _tech_reset_defaults(self):
1041
+ self.tech_fields_order = []
1042
+ self.tech_fields_enabled = set()
1043
+ self.tech_bg_color = QColor(0, 0, 0)
1044
+ self.tech_border_color = QColor("white")
1045
+ self.tech_text_fill = QColor("white")
1046
+ self.tech_text_outline = QColor("black")
1047
+ self.sp_tech_padding.setValue(16)
1048
+ self.sl_tech_bg_opacity.setValue(55)
1049
+ self.cb_tech_border.setChecked(True)
1050
+ self.sp_tech_border_w.setValue(2)
1051
+ self.cmb_tech_border_style.setCurrentText("Solid")
1052
+ self.cb_tech_hide_empty.setChecked(True)
1053
+ self._tech_init_catalog()
1054
+ self._tech_rebuild()
1055
+
1056
+ def _tech_format_text(self) -> str:
1057
+ catalog = self._build_tech_field_catalog()
1058
+ hide_empty = self.cb_tech_hide_empty.isChecked()
1059
+
1060
+ lines = []
1061
+ for k in self.tech_fields_order:
1062
+ if k not in self.tech_fields_enabled:
1063
+ continue
1064
+ v = str(catalog.get(k, "")).strip()
1065
+ if hide_empty and not v:
1066
+ continue
1067
+ # Friendly: strip full file path by default (optional)
1068
+ if k == "File" and v:
1069
+ try:
1070
+ import os
1071
+ v = os.path.basename(v)
1072
+ except Exception:
1073
+ pass
1074
+ lines.append(f"{k}: {v}" if v else f"{k}:")
1075
+
1076
+ return "\n".join(lines) if lines else "No fields selected."
1077
+
1078
+ def _tech_border_pen(self) -> QPen:
1079
+ style_map = {
1080
+ "Solid": Qt.PenStyle.SolidLine,
1081
+ "Dash": Qt.PenStyle.DashLine,
1082
+ "Dot": Qt.PenStyle.DotLine,
1083
+ "DashDot": Qt.PenStyle.DashDotLine,
1084
+ "DashDotDot": Qt.PenStyle.DashDotDotLine
1085
+ }
1086
+ pen = QPen(self.tech_border_color, float(self.sp_tech_border_w.value()), style_map[self.cmb_tech_border_style.currentText()])
1087
+ pen.setCosmetic(True)
1088
+ return pen
1089
+
1090
+ def _tech_rebuild(self, live: bool = False):
1091
+ if not self.cb_tech.isChecked():
1092
+ return
1093
+ self._tech_ensure_item()
1094
+ if self.tech_card_item is None or self.tech_card_text is None:
1095
+ return
1096
+
1097
+ # Update text styling from the existing text controls (so UI stays consistent)
1098
+ f = self._current_qfont()
1099
+ self.tech_card_text.set_font(f)
1100
+ self.tech_card_text.set_fill(self.tech_text_fill)
1101
+ ow = float(self.outline_w.value())
1102
+ if ow > 0:
1103
+ self.tech_card_text.set_outline(self.tech_text_outline, ow)
1104
+ else:
1105
+ self.tech_card_text.set_outline(None, 0.0)
1106
+
1107
+ # Update card style
1108
+ pad = int(self.sp_tech_padding.value())
1109
+ bg_op = float(self.sl_tech_bg_opacity.value()) / 100.0
1110
+ self.tech_card_item.set_padding(pad)
1111
+ self.tech_card_item.set_background(self.tech_bg_color, bg_op)
1112
+
1113
+ border_on = self.cb_tech_border.isChecked() and self.sp_tech_border_w.value() > 0
1114
+ self.tech_card_item.set_border(border_on, self._tech_border_pen())
1115
+
1116
+ # Update text content
1117
+ txt = self._tech_format_text()
1118
+ self.tech_card_item.set_text(txt)
1119
+
1120
+ # Keep transform handle correct
1121
+ self._sync_handles()
1122
+
1123
+
543
1124
  def _selected_text_items(self):
544
1125
  return [it for it in self.scene.selectedItems() if isinstance(it, QGraphicsTextItem)]
545
1126
 
@@ -741,6 +1322,10 @@ class SignatureInsertDialogPro(QDialog):
741
1322
  bg = QGraphicsPixmapItem(QPixmap.fromImage(qimg))
742
1323
  bg.setZValue(0)
743
1324
  self.scene.addItem(bg)
1325
+ self._tech_init_catalog()
1326
+ if self.cb_tech.isChecked():
1327
+ self._tech_rebuild(live=True)
1328
+
744
1329
 
745
1330
  def _load_from_file(self):
746
1331
  fp, _ = QFileDialog.getOpenFileName(self, "Select Insert Image", "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
@@ -806,6 +1391,10 @@ class SignatureInsertDialogPro(QDialog):
806
1391
  # text
807
1392
  for ti in self._selected_text_items():
808
1393
  self.send_insert_to_position(ti, key)
1394
+ # tech card
1395
+ if self.tech_card_item is not None and self.tech_card_item.isSelected():
1396
+ self.send_insert_to_position(self.tech_card_item, key)
1397
+
809
1398
 
810
1399
  # -------- Commands ----------
811
1400
  def _rotate_selected(self):
@@ -849,6 +1438,7 @@ class SignatureInsertDialogPro(QDialog):
849
1438
  }
850
1439
  self.bounding_box_pen.setWidth(self.sl_thick.value())
851
1440
  self.bounding_box_pen.setStyle(style_map[self.cmb_style.currentText()])
1441
+ self.bounding_box_pen.setCosmetic(True)
852
1442
  self._refresh_all_boxes()
853
1443
 
854
1444
  def _refresh_all_boxes(self):
@@ -968,6 +1558,8 @@ class SignatureInsertDialogPro(QDialog):
968
1558
  items.append(it)
969
1559
  elif self.bounding_boxes_enabled and isinstance(it, QGraphicsRectItem):
970
1560
  items.append(it)
1561
+ elif isinstance(it, TechCardItem):
1562
+ items.append(it)
971
1563
 
972
1564
  # compute scene bbox
973
1565
  bbox = QRectF()
@@ -1025,23 +1617,6 @@ class SignatureInsertDialogPro(QDialog):
1025
1617
  if had_focus:
1026
1618
  it.setFocus()
1027
1619
 
1028
- # temporarily hide non-items
1029
- hidden = []
1030
- for it in self.scene.items():
1031
- if it not in items:
1032
- it.setVisible(False); hidden.append(it)
1033
-
1034
- # render
1035
- out = QImage(w, h, QImage.Format.Format_ARGB32)
1036
- out.fill(Qt.GlobalColor.transparent)
1037
- p = QPainter(out)
1038
- self.scene.render(p, target=QRectF(0, 0, w, h), source=QRectF(x, y, w, h))
1039
- p.end()
1040
-
1041
- # restore
1042
- for it in hidden: it.setVisible(True)
1043
- for r in hidden_boxes: r.setVisible(True)
1044
-
1045
1620
  # drop alpha → RGB, write back to doc
1046
1621
  arr = self._qimage_to_numpy(out)
1047
1622
  if arr.shape[2] == 4:
@@ -1079,6 +1654,86 @@ class SignatureInsertDialogPro(QDialog):
1079
1654
  pass
1080
1655
  self.bounding_boxes.clear()
1081
1656
 
1657
+ def _build_tech_field_catalog(self) -> dict[str, str]:
1658
+ """
1659
+ Returns a merged catalog of:
1660
+ - header-derived fields (OBJECT/DATE-OBS/etc) from the in-memory doc header
1661
+ - convenience fields (File/Size/Bit Depth/Mono)
1662
+ """
1663
+ doc = self._tech_active_doc()
1664
+ if doc is None:
1665
+ return {}
1666
+
1667
+ meta = getattr(doc, "metadata", None) or {}
1668
+
1669
+ # --- header from DocManager (in-memory) ---
1670
+ hdr_obj = self._tech_pick_header_obj(doc)
1671
+ H = self._tech_header_to_dict(hdr_obj)
1672
+
1673
+ # Case-insensitive getter for FITS keys
1674
+ # (FITS keys are usually uppercase, but play safe)
1675
+ def hget(key: str) -> str:
1676
+ if not key:
1677
+ return ""
1678
+ if key in H:
1679
+ return H.get(key, "") or ""
1680
+ up = key.upper()
1681
+ if up in H:
1682
+ return H.get(up, "") or ""
1683
+ # last resort: scan once (small headers, OK)
1684
+ for k, v in H.items():
1685
+ if str(k).upper() == up:
1686
+ return v or ""
1687
+ return ""
1688
+
1689
+ # --- computed fields ---
1690
+ # File: prefer actual file path from metadata
1691
+ file_path = meta.get("file_path") or ""
1692
+ # Size: prefer header if available (NAXIS1/2), fallback to image shape
1693
+ naxis1 = hget("NAXIS1")
1694
+ naxis2 = hget("NAXIS2")
1695
+ if naxis1 and naxis2:
1696
+ size_str = f"{naxis1}×{naxis2}"
1697
+ else:
1698
+ img = getattr(doc, "image", None)
1699
+ try:
1700
+ if img is not None and hasattr(img, "shape"):
1701
+ if img.ndim == 2:
1702
+ h, w = img.shape[:2]
1703
+ else:
1704
+ h, w = img.shape[:2]
1705
+ size_str = f"{w}×{h}"
1706
+ else:
1707
+ size_str = ""
1708
+ except Exception:
1709
+ size_str = ""
1710
+
1711
+ bit_depth = meta.get("bit_depth") or ""
1712
+ # Mono: metadata uses is_mono in your loader; your save uses "mono" sometimes; handle both
1713
+ mono_val = meta.get("is_mono", meta.get("mono", None))
1714
+ if mono_val is None:
1715
+ img = getattr(doc, "image", None)
1716
+ mono_val = bool(img.ndim == 2) if hasattr(img, "ndim") else False
1717
+ mono_str = "Yes" if bool(mono_val) else "No"
1718
+
1719
+ # --- build catalog ---
1720
+ catalog: dict[str, str] = {}
1721
+
1722
+ # 1) Header keys: include all FITS keys as selectable fields
1723
+ # (This is why your dropdown will finally show everything in that big list.)
1724
+ for k, v in H.items():
1725
+ ks = str(k).strip()
1726
+ if not ks:
1727
+ continue
1728
+ catalog[ks] = str(v).strip()
1729
+
1730
+ # 2) Friendly extras (these are the ones you have in defaults)
1731
+ catalog.setdefault("File", str(file_path))
1732
+ catalog["Size"] = size_str
1733
+ catalog["Bit Depth"] = str(bit_depth)
1734
+ catalog["Mono"] = mono_str
1735
+
1736
+ return catalog
1082
1737
 
1083
1738
  # ------------------ numpy/QImage bridges ------------------
1084
1739
  def _numpy_to_qimage(self, a: np.ndarray) -> QImage: