setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.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 ---------------------------
@@ -366,6 +588,10 @@ class SignatureInsertDialogPro(QDialog):
366
588
  self.setWindowFlag(Qt.WindowType.Window, True)
367
589
  self.setWindowModality(Qt.WindowModality.NonModal)
368
590
  self.setModal(False)
591
+ try:
592
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
593
+ except Exception:
594
+ pass # older PyQt6 versions
369
595
  if icon:
370
596
  try: self.setWindowIcon(icon)
371
597
  except Exception as e:
@@ -380,13 +606,24 @@ class SignatureInsertDialogPro(QDialog):
380
606
  self.bounding_boxes: list[QGraphicsRectItem] = []
381
607
  self.bounding_boxes_enabled = True
382
608
  self.bounding_box_pen = QPen(QColor("red"), 2, Qt.PenStyle.DashLine)
609
+ self.bounding_box_pen.setCosmetic(True)
383
610
  self.text_inserts: list[OutlinedTextItem] = []
384
611
  self.scene.selectionChanged.connect(self._on_selection_changed)
385
612
  # Handle sync timer (keeps the handle parked on the item corner)
386
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")
387
622
 
388
623
  self._build_ui()
624
+
389
625
  self._update_base_image()
626
+ self._tech_init_catalog()
390
627
  self.resize(1000, 680)
391
628
 
392
629
  # -------- UI ----------
@@ -448,6 +685,81 @@ class SignatureInsertDialogPro(QDialog):
448
685
 
449
686
  col.addWidget(txt_grp)
450
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
+
451
763
 
452
764
  # Transform group
453
765
  grp = QGroupBox("Transform")
@@ -536,6 +848,279 @@ class SignatureInsertDialogPro(QDialog):
536
848
  root.addWidget(left, 0)
537
849
  root.addWidget(self.view, 1)
538
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
+
539
1124
  def _selected_text_items(self):
540
1125
  return [it for it in self.scene.selectedItems() if isinstance(it, QGraphicsTextItem)]
541
1126
 
@@ -737,6 +1322,10 @@ class SignatureInsertDialogPro(QDialog):
737
1322
  bg = QGraphicsPixmapItem(QPixmap.fromImage(qimg))
738
1323
  bg.setZValue(0)
739
1324
  self.scene.addItem(bg)
1325
+ self._tech_init_catalog()
1326
+ if self.cb_tech.isChecked():
1327
+ self._tech_rebuild(live=True)
1328
+
740
1329
 
741
1330
  def _load_from_file(self):
742
1331
  fp, _ = QFileDialog.getOpenFileName(self, "Select Insert Image", "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
@@ -802,6 +1391,10 @@ class SignatureInsertDialogPro(QDialog):
802
1391
  # text
803
1392
  for ti in self._selected_text_items():
804
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
+
805
1398
 
806
1399
  # -------- Commands ----------
807
1400
  def _rotate_selected(self):
@@ -845,6 +1438,7 @@ class SignatureInsertDialogPro(QDialog):
845
1438
  }
846
1439
  self.bounding_box_pen.setWidth(self.sl_thick.value())
847
1440
  self.bounding_box_pen.setStyle(style_map[self.cmb_style.currentText()])
1441
+ self.bounding_box_pen.setCosmetic(True)
848
1442
  self._refresh_all_boxes()
849
1443
 
850
1444
  def _refresh_all_boxes(self):
@@ -964,6 +1558,8 @@ class SignatureInsertDialogPro(QDialog):
964
1558
  items.append(it)
965
1559
  elif self.bounding_boxes_enabled and isinstance(it, QGraphicsRectItem):
966
1560
  items.append(it)
1561
+ elif isinstance(it, TechCardItem):
1562
+ items.append(it)
967
1563
 
968
1564
  # compute scene bbox
969
1565
  bbox = QRectF()
@@ -1021,23 +1617,6 @@ class SignatureInsertDialogPro(QDialog):
1021
1617
  if had_focus:
1022
1618
  it.setFocus()
1023
1619
 
1024
- # temporarily hide non-items
1025
- hidden = []
1026
- for it in self.scene.items():
1027
- if it not in items:
1028
- it.setVisible(False); hidden.append(it)
1029
-
1030
- # render
1031
- out = QImage(w, h, QImage.Format.Format_ARGB32)
1032
- out.fill(Qt.GlobalColor.transparent)
1033
- p = QPainter(out)
1034
- self.scene.render(p, target=QRectF(0, 0, w, h), source=QRectF(x, y, w, h))
1035
- p.end()
1036
-
1037
- # restore
1038
- for it in hidden: it.setVisible(True)
1039
- for r in hidden_boxes: r.setVisible(True)
1040
-
1041
1620
  # drop alpha → RGB, write back to doc
1042
1621
  arr = self._qimage_to_numpy(out)
1043
1622
  if arr.shape[2] == 4:
@@ -1075,6 +1654,86 @@ class SignatureInsertDialogPro(QDialog):
1075
1654
  pass
1076
1655
  self.bounding_boxes.clear()
1077
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
1078
1737
 
1079
1738
  # ------------------ numpy/QImage bridges ------------------
1080
1739
  def _numpy_to_qimage(self, a: np.ndarray) -> QImage: