setiastrosuitepro 1.6.0__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 (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,753 @@
1
+ # pro/histogram.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QSettings, QTimer, QEvent, pyqtSignal
6
+ from PyQt6.QtWidgets import (
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QScrollArea,
8
+ QTableWidget, QTableWidgetItem, QMessageBox, QToolButton, QInputDialog, QSplitter, QSizePolicy, QHeaderView
9
+ )
10
+ from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QPalette
11
+
12
+ # Shared utilities
13
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
14
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
15
+
16
+ def _to_float_preserve(img):
17
+ if img is None: return None
18
+ a = np.asarray(img)
19
+ return a.astype(np.float32, copy=False) if a.dtype != np.float32 else a
20
+
21
+
22
+
23
+ class HistogramDialog(QDialog):
24
+ """
25
+ Per-document histogram (non-modal).
26
+ - Connects to ImageDocument.changed and repaints automatically.
27
+ - Multiple dialogs can be open at once (each bound to one doc).
28
+ """
29
+ pivotPicked = pyqtSignal(float) # normalized [0..1] x position for GHS pivot
30
+ def __init__(self, parent, document):
31
+ super().__init__(parent)
32
+ self.setWindowTitle("Histogram")
33
+ self.doc = document
34
+ self.image = _to_float_preserve(document.image)
35
+
36
+ self.zoom_factor = 1.0 # 1.0 = 100%
37
+ self.log_scale = False # log X
38
+ self.log_y = False # log Y
39
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
40
+ self._eps_log = 1e-6 # first log bin edge (for labels)
41
+
42
+ # for mapping clicks → normalized x
43
+ self._click_mapping = None # dict or None
44
+ self.settings = QSettings()
45
+ self.sensor_max01 = 1.0
46
+ self.sensor_native_max = None # user ADU max (e.g., 65532)
47
+ self.native_theoretical_max = None
48
+
49
+ # histogram cache
50
+ self._bin_count = 512
51
+ self._bin_edges_lin = None # np.ndarray | None
52
+ self._bin_edges_log = None # np.ndarray | None
53
+ self._counts_lin = None # list[np.ndarray] | None
54
+ self._counts_log = None # list[np.ndarray] | None
55
+ self._is_color = False
56
+ self._eps_log = 1e-6 # first log bin edge (for labels)
57
+
58
+ self._load_sensor_max_setting()
59
+ self._build_ui()
60
+
61
+ # debounce timer for resize / splitter moves
62
+ self._resize_timer = QTimer(self)
63
+ self._resize_timer.setSingleShot(True)
64
+ self._resize_timer.setInterval(80) # ms; tweak if you want snappier/slower
65
+ self._resize_timer.timeout.connect(self._draw_histogram)
66
+
67
+ # prime histogram & stats from initial image
68
+ self._recompute_hist_cache()
69
+ self._update_stats()
70
+
71
+ # wire up to this specific document
72
+ self.doc.changed.connect(self._on_doc_changed)
73
+ # If the doc object goes away, close this dialog
74
+ self.doc.destroyed.connect(self.deleteLater)
75
+
76
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
77
+ self._doc_conn = False
78
+ if getattr(self, "doc", None) is not None:
79
+ try:
80
+ self.doc.destroyed.connect(self._on_doc_destroyed)
81
+ self._doc_conn = True
82
+ except Exception:
83
+ pass
84
+
85
+ # Do the first draw once the widget has a real size
86
+ QTimer.singleShot(0, self._draw_histogram)
87
+
88
+
89
+
90
+ # ---------- UI ----------
91
+ def _build_ui(self):
92
+ # Make it start at a sensible size
93
+ self.setMinimumSize(800, 400)
94
+ self.resize(900, 500)
95
+
96
+ main_layout = QVBoxLayout(self)
97
+
98
+ # --- top area: splitter with histogram + stats ---
99
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
100
+
101
+ # left: scroll area + label for the pixmap
102
+ self.scroll_area = QScrollArea(self)
103
+ self.scroll_area.setWidgetResizable(True)
104
+ self.scroll_area.setSizePolicy(
105
+ QSizePolicy.Policy.Expanding,
106
+ QSizePolicy.Policy.Expanding,
107
+ )
108
+
109
+ self.hist_label = QLabel(self)
110
+ self.hist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
111
+ self.scroll_area.setWidget(self.hist_label)
112
+ self.hist_label.installEventFilter(self)
113
+ self.hist_label.setToolTip(
114
+ "Ctrl+Click on the histogram to send that intensity as the "
115
+ "pivot to Hyperbolic Stretch (if open)."
116
+ )
117
+ self.scroll_area.viewport().installEventFilter(self)
118
+
119
+ splitter.addWidget(self.scroll_area)
120
+
121
+ # right: stats table
122
+ self.stats_table = QTableWidget(self)
123
+ self.stats_table.setRowCount(7)
124
+ self.stats_table.setColumnCount(1)
125
+ self.stats_table.setVerticalHeaderLabels([
126
+ "Min", "Max", "Median", "StdDev",
127
+ "MAD", "Low Clipped", "High Clipped"
128
+ ])
129
+
130
+ # Let it grow/shrink with the splitter
131
+ self.stats_table.setMinimumWidth(320)
132
+ self.stats_table.setSizePolicy(
133
+ QSizePolicy.Policy.Preferred, # <- was Fixed
134
+ QSizePolicy.Policy.Expanding,
135
+ )
136
+
137
+ # Make the columns use available width nicely
138
+ hdr = self.stats_table.horizontalHeader()
139
+ hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
140
+ # hdr.setStretchLastSection(True)
141
+ splitter.addWidget(self.stats_table)
142
+
143
+ # Give more space to histogram side by default
144
+ splitter.setStretchFactor(0, 3)
145
+ splitter.setStretchFactor(1, 1)
146
+ # Explicit initial sizes so it doesn't start with a tiny histogram
147
+ splitter.setSizes([650, 250])
148
+
149
+ QTimer.singleShot(0, self._adjust_stats_width)
150
+
151
+ main_layout.addWidget(splitter)
152
+
153
+ # --- controls row (unchanged except for being below splitter) ---
154
+ ctl = QHBoxLayout()
155
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal, self)
156
+ self.zoom_slider.setRange(50, 1000)
157
+ self.zoom_slider.setValue(100)
158
+ self.zoom_slider.setTickInterval(10)
159
+ self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
160
+ self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
161
+
162
+ ctl.addWidget(QLabel("Zoom:"))
163
+ ctl.addWidget(self.zoom_slider)
164
+
165
+ self.btn_logx = QPushButton("Toggle Log X-Axis", self)
166
+ self.btn_logx.setCheckable(True)
167
+ self.btn_logx.toggled.connect(self._toggle_log_x)
168
+ ctl.addWidget(self.btn_logx)
169
+
170
+ self.btn_logy = QPushButton("Toggle Log Y-Axis", self)
171
+ self.btn_logy.setCheckable(True)
172
+ self.btn_logy.toggled.connect(self._toggle_log_y)
173
+ ctl.addWidget(self.btn_logy)
174
+
175
+ self.btn_sensor_max = QToolButton(self)
176
+ self.btn_sensor_max.setText("?")
177
+ self.btn_sensor_max.setToolTip(
178
+ "Set your camera's true saturation level for clipping warnings.\n"
179
+ "Tip: take an overexposed frame and see its max ADU."
180
+ )
181
+ self.btn_sensor_max.clicked.connect(self._prompt_sensor_max)
182
+ ctl.addWidget(self.btn_sensor_max)
183
+
184
+ main_layout.addLayout(ctl)
185
+
186
+ btn_close = QPushButton("Close", self)
187
+ btn_close.clicked.connect(self.accept)
188
+ main_layout.addWidget(btn_close)
189
+
190
+ self.setLayout(main_layout)
191
+
192
+
193
+
194
+ # ---------- slots ----------
195
+ def _on_doc_changed(self):
196
+ self.image = _to_float_preserve(self.doc.image)
197
+ self._recompute_hist_cache()
198
+ self._update_stats()
199
+ self._draw_histogram()
200
+
201
+ def _on_zoom_changed(self, v: int):
202
+ self.zoom_factor = v / 100.0
203
+ self._draw_histogram()
204
+
205
+ def _toggle_log_x(self, on: bool):
206
+ self.log_scale = bool(on)
207
+ self._draw_histogram()
208
+
209
+ def _toggle_log_y(self, on: bool):
210
+ self.log_y = bool(on)
211
+ self._draw_histogram()
212
+
213
+ # ---------- drawing ----------
214
+ # ---------- drawing ----------
215
+ def _draw_histogram(self):
216
+ # nothing to draw yet
217
+ if self.image is None or self._bin_edges_lin is None:
218
+ self.hist_label.clear()
219
+ return
220
+
221
+ # use available size in the scroll area's viewport
222
+ if self.scroll_area is not None:
223
+ vp = self.scroll_area.viewport()
224
+ avail_w = max(200, vp.width())
225
+ avail_h = max(200, vp.height())
226
+ else:
227
+ avail_w = 512
228
+ avail_h = 300
229
+
230
+ base_width = avail_w
231
+ height = avail_h
232
+ width = int(base_width * self.zoom_factor)
233
+
234
+ # layout margins
235
+ left_margin = 32 # room for Y labels
236
+ top_margin = 12 # room so top ticks/text aren't clipped
237
+ bottom_margin = 24 # room for X labels
238
+ axis_y = height - bottom_margin
239
+ usable_h = max(1, axis_y - top_margin)
240
+ plot_width = max(1, width - left_margin)
241
+
242
+ # choose edges + raw counts from cache
243
+ if self.log_scale:
244
+ bin_edges = self._bin_edges_log
245
+ counts_list = self._counts_log
246
+ else:
247
+ bin_edges = self._bin_edges_lin
248
+ counts_list = self._counts_lin
249
+
250
+ if bin_edges is None or counts_list is None:
251
+ self.hist_label.clear()
252
+ return
253
+
254
+ bin_count = len(bin_edges) - 1
255
+
256
+ # precompute log range if needed
257
+ if self.log_scale:
258
+ # guard: avoid log10(<=0)
259
+ be0 = float(bin_edges[0])
260
+ if be0 <= 0:
261
+ be0 = self._eps_log
262
+ log_min = np.log10(be0)
263
+ log_max = 0.0
264
+ else:
265
+ log_min = None
266
+ log_max = None
267
+
268
+ # map X-domain edge → pixel X
269
+ def x_pos(edge: float) -> int:
270
+ if self.log_scale:
271
+ if edge <= 0:
272
+ edge = self._eps_log
273
+ if abs(log_max - log_min) < 1e-12:
274
+ return left_margin
275
+ return left_margin + int(
276
+ (np.log10(edge) - log_min) / (log_max - log_min) * plot_width
277
+ )
278
+ else:
279
+ return left_margin + int(edge * plot_width)
280
+
281
+ # --- convert counts → display values (linear or log Y) ---
282
+ vals_list: list[np.ndarray] = []
283
+ max_val = 0.0
284
+ for counts in counts_list:
285
+ if self.log_y:
286
+ vals = np.log10(counts + 1.0)
287
+ else:
288
+ vals = counts.astype(np.float32)
289
+ if vals.size:
290
+ max_val = max(max_val, float(vals.max()))
291
+ vals_list.append(vals)
292
+
293
+ if max_val <= 0:
294
+ max_val = 1.0
295
+
296
+ # theme colors
297
+ pal = self.window().palette() if self.window() else self.palette()
298
+ bg_color = pal.color(QPalette.ColorRole.Window)
299
+ text_color = pal.color(QPalette.ColorRole.Text)
300
+
301
+ if bg_color.lightness() < 128:
302
+ axis_color = QColor(210, 210, 210)
303
+ label_color = QColor(245, 245, 245)
304
+ else:
305
+ axis_color = QColor(40, 40, 40)
306
+ label_color = text_color
307
+
308
+ grid_color = QColor(axis_color)
309
+ grid_color.setAlpha(60)
310
+ grid_pen = QPen(grid_color)
311
+ grid_pen.setWidth(1)
312
+
313
+ pm = QPixmap(width, height)
314
+ pm.fill(bg_color)
315
+ p = QPainter(pm)
316
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
317
+
318
+ # helper: map normalized [0,1] → Y pixel (0 at bottom, 1 at top)
319
+ def y_pos(norm: float) -> int:
320
+ # norm in [0,1], map 0→axis_y, 1→top_margin
321
+ return int(top_margin + (1.0 - norm) * usable_h)
322
+
323
+ # ----- draw bars -----
324
+ if self._is_color:
325
+ colors = [
326
+ QColor(255, 0, 0, 140),
327
+ QColor(0, 180, 0, 140),
328
+ QColor(0, 0, 255, 140),
329
+ ]
330
+ for ch_idx, vals in enumerate(vals_list):
331
+ hn = vals / max_val
332
+ p.setPen(QPen(colors[ch_idx]))
333
+ for i in range(bin_count):
334
+ x0 = x_pos(float(bin_edges[i]))
335
+ x1 = x_pos(float(bin_edges[i + 1]))
336
+ w = max(1, x1 - x0)
337
+ h = int(hn[i] * usable_h)
338
+ y0 = axis_y - h
339
+ p.drawRect(x0, y0, w, h)
340
+ else:
341
+ vals = vals_list[0]
342
+ hn = vals / max_val
343
+ p.setPen(QPen(axis_color))
344
+ for i in range(bin_count):
345
+ x0 = x_pos(float(bin_edges[i]))
346
+ x1 = x_pos(float(bin_edges[i + 1]))
347
+ w = max(1, x1 - x0)
348
+ h = int(hn[i] * usable_h)
349
+ y0 = axis_y - h
350
+ p.drawRect(x0, y0, w, h)
351
+
352
+ # ----- axes -----
353
+ p.setPen(QPen(axis_color, 2))
354
+ # X axis at axis_y, Y axis from top_margin down to axis_y
355
+ p.drawLine(left_margin, axis_y, width - 1, axis_y)
356
+ p.drawLine(left_margin, top_margin, left_margin, axis_y)
357
+
358
+ p.setFont(QFont("Arial", 10))
359
+
360
+ # ----- X ticks + grid -----
361
+ if self.log_scale:
362
+ ticks = np.logspace(np.log10(bin_edges[0]), 0.0, 11)
363
+ for t in ticks:
364
+ x = x_pos(float(t))
365
+ if left_margin < x < width - 1:
366
+ p.setPen(grid_pen)
367
+ p.drawLine(x, top_margin, x, axis_y)
368
+ p.setPen(axis_color)
369
+ p.drawLine(x, axis_y, x, axis_y - 5)
370
+ p.setPen(label_color)
371
+ p.drawText(x - 18, axis_y + bottom_margin - 8, f"{t:.3f}")
372
+ else:
373
+ ticks = np.linspace(0.0, 1.0, 11)
374
+ for t in ticks:
375
+ x = x_pos(float(t))
376
+ if left_margin < x < width - 1:
377
+ p.setPen(grid_pen)
378
+ p.drawLine(x, top_margin, x, axis_y)
379
+ p.setPen(axis_color)
380
+ p.drawLine(x, axis_y, x, axis_y - 5)
381
+ p.setPen(label_color)
382
+ p.drawText(x - 10, axis_y + bottom_margin - 8, f"{t:.1f}")
383
+
384
+ # ----- Y ticks + grid -----
385
+ n_yticks = 6
386
+ if self.log_y:
387
+ exps = np.linspace(0.0, max_val, n_yticks)
388
+ norms = exps / max_val
389
+ labels = [f"{10**e:.0f}" for e in exps]
390
+ else:
391
+ vals_for_ticks = np.linspace(0.0, max_val, n_yticks)
392
+ norms = vals_for_ticks / max_val
393
+ labels = [f"{v:.0f}" for v in vals_for_ticks]
394
+
395
+ for i, (yn, lab) in enumerate(zip(norms, labels)):
396
+ y = y_pos(float(yn))
397
+ if 0 < i < n_yticks - 1:
398
+ p.setPen(grid_pen)
399
+ p.drawLine(left_margin, y, width - 1, y)
400
+ p.setPen(axis_color)
401
+ p.drawLine(left_margin - 5, y, left_margin, y)
402
+ p.setPen(label_color)
403
+ p.drawText(2, y + 4, lab)
404
+
405
+ # --- draw effective-max marker if user set one ---
406
+ if self.sensor_max01 < 0.9999:
407
+ x = x_pos(self.sensor_max01)
408
+ p.setPen(QPen(QColor(220, 0, 0), 2, Qt.PenStyle.DashLine))
409
+ p.drawLine(x, top_margin, x, axis_y)
410
+ p.drawText(min(x + 4, width - 80), top_margin + 12,
411
+ f"True Max {self.sensor_max01:.4f}")
412
+ # store mapping info for Ctrl+click → normalized x
413
+ try:
414
+ self._click_mapping = {
415
+ "left_margin": left_margin,
416
+ "plot_width": plot_width,
417
+ "axis_y": axis_y,
418
+ "top_margin": top_margin,
419
+ "height": height,
420
+ "log_scale": bool(self.log_scale),
421
+ "log_min": log_min,
422
+ "log_max": log_max,
423
+ }
424
+ except Exception:
425
+ self._click_mapping = None
426
+ p.end()
427
+ self.hist_label.setPixmap(pm)
428
+ self.hist_label.resize(pm.size())
429
+
430
+ def _x_pix_to_u(self, x_pix: int) -> float | None:
431
+ """
432
+ Map a horizontal pixel coordinate (in the label) to a normalized
433
+ intensity in [0..1], respecting linear / log X modes.
434
+ """
435
+ m = self._click_mapping
436
+ if not m:
437
+ return None
438
+
439
+ left = m["left_margin"]
440
+ width = max(1, m["plot_width"])
441
+ if x_pix < left or x_pix > left + width:
442
+ return None
443
+
444
+ t = (x_pix - left) / float(width)
445
+ t = max(0.0, min(1.0, t))
446
+
447
+ if not m["log_scale"]:
448
+ # linear: domain is already [0..1]
449
+ return float(t)
450
+
451
+ # log X: t in [0..1] corresponds to [10^log_min .. 10^log_max] (log_max ~ 0)
452
+ log_min = m.get("log_min", None)
453
+ log_max = m.get("log_max", None)
454
+ if log_min is None or log_max is None or abs(log_max - log_min) < 1e-12:
455
+ return float(t)
456
+
457
+ log_v = log_min + t * (log_max - log_min)
458
+ v = 10.0 ** log_v
459
+ # v is in (eps .. 1]; clamp to [0..1]
460
+ return float(max(0.0, min(1.0, v)))
461
+
462
+
463
+ def _recompute_hist_cache(self):
464
+ """Compute histograms once for the current image.
465
+
466
+ This is called when the document image changes. Resizing / zooming
467
+ will only redraw using this cached data.
468
+ """
469
+ img = self.image
470
+ self._bin_edges_lin = None
471
+ self._bin_edges_log = None
472
+ self._counts_lin = None
473
+ self._counts_log = None
474
+ self._is_color = False
475
+ self._eps_log = 1e-6
476
+
477
+ if img is None:
478
+ return
479
+
480
+ a = img
481
+ if a.ndim == 3 and a.shape[2] == 1:
482
+ a = a[..., 0]
483
+
484
+ if a.ndim == 3 and a.shape[2] == 3:
485
+ chans = [a[..., i] for i in range(3)]
486
+ self._is_color = True
487
+ else:
488
+ chan = a if a.ndim == 2 else a[..., 0]
489
+ chans = [chan]
490
+ self._is_color = False
491
+
492
+ bin_count = self._bin_count
493
+
494
+ # --- linear X bins ---
495
+ bin_edges_lin = np.linspace(0.0, 1.0, bin_count + 1).astype(np.float32)
496
+ counts_lin: list[np.ndarray] = []
497
+ for c in chans:
498
+ counts, _ = np.histogram(c.ravel(), bins=bin_edges_lin)
499
+ counts_lin.append(counts.astype(np.float32))
500
+
501
+ # --- log X bins ---
502
+ pos = a[a > 0]
503
+ eps = max(1e-6, float(pos.min())) if pos.size else 1e-6
504
+ log_min, log_max = np.log10(eps), 0.0
505
+ if abs(log_max - log_min) < 1e-12:
506
+ bin_edges_log = np.linspace(eps, 1.0, bin_count + 1).astype(np.float32)
507
+ else:
508
+ bin_edges_log = np.logspace(log_min, log_max, bin_count + 1).astype(np.float32)
509
+
510
+ counts_log: list[np.ndarray] = []
511
+ for c in chans:
512
+ counts, _ = np.histogram(c.ravel(), bins=bin_edges_log)
513
+ counts_log.append(counts.astype(np.float32))
514
+
515
+ self._bin_edges_lin = bin_edges_lin
516
+ self._bin_edges_log = bin_edges_log
517
+ self._counts_lin = counts_lin
518
+ self._counts_log = counts_log
519
+ self._eps_log = float(eps)
520
+
521
+
522
+ def _schedule_redraw(self):
523
+ # Only bother if visible; restart timer each time
524
+ if self.isVisible():
525
+ self._resize_timer.start()
526
+
527
+ def resizeEvent(self, event):
528
+ super().resizeEvent(event)
529
+ self._schedule_redraw()
530
+
531
+ def eventFilter(self, obj, event):
532
+ # Ctrl+click on the histogram pixmap → emit pivotPicked(u)
533
+ if obj is self.hist_label and event.type() == QEvent.Type.MouseButtonPress:
534
+ if (event.button() == Qt.MouseButton.LeftButton and
535
+ (event.modifiers() & Qt.KeyboardModifier.ControlModifier)):
536
+ pos = event.position().toPoint()
537
+ u = self._x_pix_to_u(pos.x())
538
+ if u is not None:
539
+ # emit normalized pivot in [0..1]
540
+ self.pivotPicked.emit(u)
541
+ event.accept()
542
+ return True
543
+
544
+ # When the splitter moves, the scroll_area viewport gets a Resize event
545
+ if self.scroll_area is not None and obj is self.scroll_area.viewport():
546
+ if event.type() == QEvent.Type.Resize:
547
+ self._schedule_redraw()
548
+
549
+ return super().eventFilter(obj, event)
550
+
551
+ def _update_stats(self):
552
+ if self.image is None:
553
+ return
554
+
555
+ img = self.image
556
+ # determine channels
557
+ if img.ndim == 3 and img.shape[2] == 3:
558
+ chans = [img[..., i] for i in range(3)]
559
+ self.stats_table.setColumnCount(3)
560
+ self.stats_table.setHorizontalHeaderLabels(["R", "G", "B"])
561
+ else:
562
+ chan = img if img.ndim == 2 else img[..., 0]
563
+ chans = [chan]
564
+ self.stats_table.setColumnCount(1)
565
+ self.stats_table.setHorizontalHeaderLabels(["Gray"])
566
+
567
+ eps = 1e-6 # tolerance for "exactly 0/1" after float ops
568
+
569
+ row_defs = [
570
+ ("Min", lambda c: float(np.min(c)), "{:.4f}"),
571
+ ("Max", lambda c: float(np.max(c)), "{:.4f}"),
572
+ ("Median", lambda c: float(np.median(c)), "{:.4f}"),
573
+ ("StdDev", lambda c: float(np.std(c)), "{:.4f}"),
574
+ ("MAD", lambda c: float(np.median(np.abs(c - np.median(c)))), "{:.4f}"),
575
+ ("Low Clipped", lambda c: _clip_fmt(c, low=True, eps=eps), "{}"),
576
+ ("High Clipped", lambda c: _clip_fmt(c, low=False, eps=eps), "{}"),
577
+ ]
578
+
579
+ def _clip_fmt(c, low: bool, eps: float):
580
+ flat = np.ravel(c)
581
+ n = flat.size if flat.size else 1
582
+ if low:
583
+ k = int(np.count_nonzero(flat <= eps))
584
+ else:
585
+ hi_thr = max(eps, self.sensor_max01 - eps)
586
+ k = int(np.count_nonzero(flat >= hi_thr))
587
+ pct = 100.0 * k / n
588
+ return f"{k} ({pct:.3f}%)"
589
+
590
+ # apply labels + sizes
591
+ self.stats_table.setRowCount(len(row_defs))
592
+ self.stats_table.setVerticalHeaderLabels([lab for lab, _, _ in row_defs])
593
+
594
+ # fill cells
595
+ for r, (lab, fn, fmt) in enumerate(row_defs):
596
+ for c_idx, c_arr in enumerate(chans):
597
+ val = fn(c_arr)
598
+ text = fmt.format(val)
599
+ it = QTableWidgetItem(text)
600
+ it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
601
+
602
+ # --- visual pop for non-trivial clipping ---
603
+ if lab in ("Low Clipped", "High Clipped"):
604
+ # text looks like: "123 (0.456%)"
605
+ try:
606
+ pct_str = text.split("(")[1].split("%")[0]
607
+ pct = float(pct_str)
608
+ except Exception:
609
+ pct = 0.0
610
+
611
+ # thresholds you can tweak
612
+ # <0.01%: ignore
613
+ # 0.01–0.1%: mild warning
614
+ # 0.1–1%: clear warning
615
+ # >1%: strong warning
616
+ if pct >= 1.0:
617
+ it.setBackground(QColor(100, 30, 30)) # strong red tint
618
+ elif pct >= 0.1:
619
+ it.setBackground(QColor(70, 30, 30)) # medium red tint
620
+ elif pct >= 0.01:
621
+ it.setBackground(QColor(40, 30, 30)) # mild red tint
622
+
623
+ self.stats_table.setItem(r, c_idx, it)
624
+
625
+ self._adjust_stats_width()
626
+
627
+ def _theoretical_native_max_from_meta(self):
628
+ meta = getattr(self.doc, "metadata", None) or {}
629
+ bd = str(meta.get("bit_depth", "")).lower()
630
+
631
+ if "16-bit" in bd:
632
+ return 65535
633
+ if "8-bit" in bd:
634
+ return 255
635
+ if "32-bit unsigned" in bd:
636
+ return 4294967295
637
+ return None
638
+
639
+ def _settings_key_for_native_max(self, native_theoretical_max):
640
+ if native_theoretical_max == 65535:
641
+ return "histogram/sensor_max_native_16"
642
+ if native_theoretical_max == 255:
643
+ return "histogram/sensor_max_native_8"
644
+ if native_theoretical_max == 4294967295:
645
+ return "histogram/sensor_max_native_32u"
646
+ return "histogram/sensor_max_native_generic"
647
+
648
+ def _load_sensor_max_setting(self):
649
+ self.native_theoretical_max = self._theoretical_native_max_from_meta()
650
+ if self.native_theoretical_max:
651
+ key = self._settings_key_for_native_max(self.native_theoretical_max)
652
+ val = self.settings.value(key, None)
653
+ if val is not None:
654
+ try:
655
+ self.sensor_native_max = float(val)
656
+ except Exception:
657
+ self.sensor_native_max = None
658
+
659
+ self._recompute_effective_max01()
660
+
661
+ def _recompute_effective_max01(self):
662
+ if self.native_theoretical_max and self.sensor_native_max:
663
+ self.sensor_max01 = float(self.sensor_native_max) / float(self.native_theoretical_max)
664
+ self.sensor_max01 = float(np.clip(self.sensor_max01, 1e-6, 1.0))
665
+ else:
666
+ self.sensor_max01 = 1.0
667
+
668
+ def _prompt_sensor_max(self):
669
+ self.native_theoretical_max = self._theoretical_native_max_from_meta()
670
+
671
+ if self.native_theoretical_max:
672
+ key = self._settings_key_for_native_max(self.native_theoretical_max)
673
+ current = self.sensor_native_max or self.native_theoretical_max
674
+
675
+ val, ok = QInputDialog.getInt(
676
+ self,
677
+ "Sensor True Max (ADU)",
678
+ f"Enter your sensor's true saturation value in native ADU.\n"
679
+ f"(Typical max for this file type is {self.native_theoretical_max})\n\n"
680
+ "You can measure this by taking a deliberately overexposed frame\n"
681
+ "and reading its maximum pixel value.",
682
+ int(current),
683
+ 1,
684
+ int(self.native_theoretical_max)
685
+ )
686
+ if ok:
687
+ self.sensor_native_max = float(val)
688
+ self.settings.setValue(key, float(val))
689
+ else:
690
+ # float images / unknown depth: allow normalized max
691
+ val, ok = QInputDialog.getDouble(
692
+ self,
693
+ "Histogram Effective Max",
694
+ "Enter effective maximum for clipping (normalized units).",
695
+ float(self.sensor_max01),
696
+ 1e-6,
697
+ 1.0,
698
+ 6
699
+ )
700
+ if ok:
701
+ self.sensor_max01 = float(val)
702
+ self.settings.setValue("histogram/sensor_max01_generic", float(val))
703
+
704
+ self._recompute_effective_max01()
705
+ self._update_stats() # High Clipped row depends on sensor_max01
706
+ self._draw_histogram()
707
+
708
+ def _adjust_stats_width(self):
709
+ """Resize stats table so all columns are visible without a scrollbar."""
710
+ if not self.stats_table:
711
+ return
712
+
713
+ # Let Qt compute natural column widths
714
+ self.stats_table.resizeColumnsToContents()
715
+ self.stats_table.resizeRowsToContents()
716
+
717
+ vh = self.stats_table.verticalHeader()
718
+ frame = self.stats_table.frameWidth()
719
+
720
+ total_w = vh.width() + 2 * frame
721
+
722
+ for col in range(self.stats_table.columnCount()):
723
+ total_w += self.stats_table.columnWidth(col)
724
+
725
+ # Room for a possible vertical scrollbar
726
+ vbar = self.stats_table.verticalScrollBar()
727
+ if vbar is not None:
728
+ total_w += vbar.sizeHint().width()
729
+
730
+ # A tiny padding so text isn't tight
731
+ total_w += 6
732
+
733
+ self.stats_table.setMinimumWidth(total_w)
734
+
735
+
736
+ def _on_doc_destroyed(self, *args):
737
+ # Called when the owner/document goes away.
738
+ try:
739
+ # Avoid re-entrancy; schedule deletion safely.
740
+ self.deleteLater()
741
+ except RuntimeError:
742
+ pass
743
+
744
+ def closeEvent(self, event):
745
+ # Cleanly disconnect to avoid stray callbacks.
746
+ if getattr(self, "_doc_conn", False) and getattr(self, "doc", None) is not None:
747
+ try:
748
+ self.doc.destroyed.disconnect(self._on_doc_destroyed)
749
+ except (TypeError, RuntimeError):
750
+ pass
751
+ self._doc_conn = False
752
+
753
+ super().closeEvent(event)