setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from PyQt6.QtCore import Qt, QSize, QEvent
4
4
  from PyQt6.QtWidgets import (
5
5
  QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QDoubleSpinBox,
6
- QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton, QComboBox
6
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QSlider, QToolBar, QToolButton, QComboBox,QProgressBar, QApplication
7
7
  )
8
8
  from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
9
9
  import numpy as np
@@ -243,6 +243,32 @@ class StatisticalStretchDialog(QDialog):
243
243
  lr.addWidget(QLabel(self.tr("Mode:")))
244
244
  lr.addWidget(self.cmb_luma, 1)
245
245
 
246
+ # --- Luma blend row (only meaningful when Luma-only is enabled) ---
247
+ self.luma_blend_row = QWidget()
248
+ lbr = QHBoxLayout(self.luma_blend_row)
249
+ lbr.setContentsMargins(0, 0, 0, 0)
250
+ lbr.setSpacing(8)
251
+
252
+ lbr.addWidget(QLabel(self.tr("Luma blend:")))
253
+
254
+ self.sld_luma_blend = QSlider(Qt.Orientation.Horizontal)
255
+ self.sld_luma_blend.setRange(0, 100) # 0=normal linked, 100=luma-only
256
+ self.sld_luma_blend.setValue(60) # nice default: “mostly luma” but tame
257
+ lbr.addWidget(self.sld_luma_blend, 1)
258
+
259
+ self.lbl_luma_blend = QLabel(f"{self.sld_luma_blend.value()/100:.2f}")
260
+ lbr.addWidget(self.lbl_luma_blend)
261
+
262
+ tip = self.tr(
263
+ "Blend between a normal linked RGB stretch (0.00) and a luminance-only stretch (1.00).\n"
264
+ "Use this to tame the saturation punch of luma-only."
265
+ )
266
+ self.luma_blend_row.setToolTip(tip)
267
+ self.sld_luma_blend.setToolTip(tip)
268
+ self.lbl_luma_blend.setToolTip(tip)
269
+
270
+ self.luma_blend_row.setEnabled(False)
271
+
246
272
  # --- Curves boost ---
247
273
  self.chk_curves = QCheckBox(self.tr("Curves boost"))
248
274
  self.chk_curves.setChecked(False)
@@ -294,6 +320,7 @@ class StatisticalStretchDialog(QDialog):
294
320
  self.btn_preview = QPushButton(self.tr("Preview"))
295
321
  self.btn_apply = QPushButton(self.tr("Apply"))
296
322
  self.btn_close = QPushButton(self.tr("Close"))
323
+ self.btn_reset = QPushButton(self.tr("Reset ⟳"))
297
324
 
298
325
  self.btn_clipstats = QPushButton(self.tr("Clip stats"))
299
326
  self.lbl_clipstats = QLabel("")
@@ -304,6 +331,24 @@ class StatisticalStretchDialog(QDialog):
304
331
  self.lbl_clipstats.setFrameShadow(QLabel.Shadow.Sunken)
305
332
  self.lbl_clipstats.setContentsMargins(6, 4, 6, 4)
306
333
 
334
+ # --- In-UI busy indicator (Wayland-friendly) ---
335
+ self.busy_row = QWidget()
336
+ br = QHBoxLayout(self.busy_row)
337
+ br.setContentsMargins(0, 0, 0, 0)
338
+ br.setSpacing(8)
339
+
340
+ self.lbl_busy = QLabel(self.tr("Processing…"))
341
+ self.lbl_busy.setStyleSheet("color:#888;")
342
+ self.pbar_busy = QProgressBar()
343
+ self.pbar_busy.setRange(0, 0) # indeterminate
344
+ self.pbar_busy.setTextVisible(False)
345
+ self.pbar_busy.setFixedHeight(10)
346
+
347
+ br.addWidget(self.lbl_busy)
348
+ br.addWidget(self.pbar_busy, 1)
349
+
350
+ self.busy_row.setVisible(False) # hidden until needed
351
+
307
352
  # ------------------------------------------------------------------
308
353
  # Layout
309
354
  # ------------------------------------------------------------------
@@ -315,6 +360,7 @@ class StatisticalStretchDialog(QDialog):
315
360
  form.addRow("", self.chk_hdr)
316
361
  form.addRow("", self.hdr_row)
317
362
  form.addRow("", self.luma_row)
363
+ form.addRow("", self.luma_blend_row)
318
364
  form.addRow("", self.chk_normalize)
319
365
  form.addRow("", self.chk_curves)
320
366
  form.addRow("", self.curves_row)
@@ -326,10 +372,13 @@ class StatisticalStretchDialog(QDialog):
326
372
  btn_row.addWidget(self.btn_preview)
327
373
  btn_row.addWidget(self.btn_apply)
328
374
  btn_row.addWidget(self.btn_clipstats)
375
+ btn_row.addStretch(1)
376
+ btn_row.addWidget(self.btn_reset)
329
377
  btn_row.addStretch(1)
330
378
  left.addLayout(btn_row)
331
379
 
332
380
  left.addWidget(self.lbl_clipstats)
381
+ left.addWidget(self.busy_row)
333
382
  left.addStretch(1)
334
383
 
335
384
  right = QVBoxLayout()
@@ -382,14 +431,6 @@ class StatisticalStretchDialog(QDialog):
382
431
 
383
432
  self.spin_target.valueChanged.connect(_suggest_hdr_knee_from_target)
384
433
 
385
- # Luma-only: enables dropdown, disables "linked channels"
386
- self.chk_luma_only.toggled.connect(self.cmb_luma.setEnabled)
387
-
388
- def _on_luma_only_toggled(on: bool):
389
- self.chk_linked.setEnabled(not on)
390
-
391
- self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
392
-
393
434
  # Zoom buttons
394
435
  self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
395
436
  self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
@@ -399,6 +440,7 @@ class StatisticalStretchDialog(QDialog):
399
440
  # Main buttons
400
441
  self.btn_preview.clicked.connect(self._do_preview)
401
442
  self.btn_apply.clicked.connect(self._do_apply)
443
+ self.btn_reset.clicked.connect(self._reset_defaults)
402
444
  self.btn_close.clicked.connect(self.close)
403
445
  self.btn_clipstats.clicked.connect(self._do_clip_stats)
404
446
 
@@ -410,50 +452,191 @@ class StatisticalStretchDialog(QDialog):
410
452
 
411
453
  # Initialize UI state
412
454
  _suggest_hdr_knee_from_target()
455
+ self.sld_luma_blend.valueChanged.connect(
456
+ lambda v: self.lbl_luma_blend.setText(f"{v/100:.2f}")
457
+ )
458
+
459
+ # Luma-only: one unified handler for all dependent UI state
460
+ def _on_luma_only_toggled(on: bool):
461
+ # enable luma mode dropdown only when luma-only is on
462
+ self.cmb_luma.setEnabled(on)
463
+
464
+ # linked channels doesn't make sense in luma-only mode
465
+ self.chk_linked.setEnabled(not on)
466
+
467
+ # luma blend row only meaningful when luma-only is enabled
468
+ self.luma_blend_row.setEnabled(on)
469
+
470
+ # mode-affecting => refresh clip stats
471
+ self._schedule_clip_stats()
472
+
473
+ self.chk_luma_only.toggled.connect(_on_luma_only_toggled)
413
474
  _on_luma_only_toggled(self.chk_luma_only.isChecked())
414
475
 
476
+
415
477
  # Initial preview + clip stats
416
478
  self._populate_initial_preview()
417
479
 
418
480
 
419
481
  # ----- helpers -----
420
482
  def _show_busy(self, title: str, text: str):
421
- # Avoid stacking dialogs
422
- self._hide_busy()
423
-
424
- dlg = QProgressDialog(text, None, 0, 0, self)
425
- dlg.setWindowTitle(title)
426
- dlg.setWindowModality(Qt.WindowModality.WindowModal) # blocks only this tool window
427
- dlg.setMinimumDuration(0)
428
- dlg.setValue(0)
429
- dlg.setCancelButton(None) # no cancel button (keeps it simple)
430
- dlg.setAutoClose(False)
431
- dlg.setAutoReset(False)
432
- dlg.setFixedWidth(320)
433
- dlg.show()
434
-
435
- # Ensure it paints before heavy work starts
436
- QApplication.processEvents()
437
- self._busy = dlg
483
+ # title kept for signature compatibility; not shown
484
+ try:
485
+ self.lbl_busy.setText(text or self.tr("Processing…"))
486
+ self.busy_row.setVisible(True)
487
+ # make sure UI repaints before thread work starts
488
+ QApplication.processEvents()
489
+ except Exception:
490
+ pass
438
491
 
439
492
  def _hide_busy(self):
440
493
  try:
441
- if getattr(self, "_busy", None) is not None:
442
- self._busy.close()
443
- self._busy.deleteLater()
494
+ if getattr(self, "busy_row", None) is not None:
495
+ self.busy_row.setVisible(False)
444
496
  except Exception:
445
497
  pass
446
- self._busy = None
498
+
447
499
 
448
500
  def _set_controls_enabled(self, enabled: bool):
449
501
  try:
450
502
  self.btn_preview.setEnabled(enabled)
451
503
  self.btn_apply.setEnabled(enabled)
504
+ if getattr(self, "btn_reset", None) is not None:
505
+ self.btn_reset.setEnabled(enabled) # <-- NEW
452
506
  if getattr(self, "btn_clipstats", None) is not None:
453
507
  self.btn_clipstats.setEnabled(enabled)
454
508
  except Exception:
455
509
  pass
456
510
 
511
+ def _reset_defaults(self):
512
+ """Reset all controls back to factory defaults."""
513
+ if getattr(self, "_job_running", False):
514
+ return
515
+
516
+ # Defaults (must match your __init__ setValue/setChecked calls)
517
+ DEFAULT_TARGET = 0.25
518
+ DEFAULT_LINKED = False
519
+ DEFAULT_NORMALIZE = False
520
+ DEFAULT_BP_SLIDER = 500 # 5.00
521
+ DEFAULT_NO_BLACK_CLIP = False
522
+
523
+ DEFAULT_HDR_ON = False
524
+ DEFAULT_HDR_AMT = 15 # 0.15
525
+ DEFAULT_HDR_KNEE = 75 # 0.75
526
+
527
+ DEFAULT_LUMA_ONLY = False
528
+ DEFAULT_LUMA_MODE = "rec709"
529
+ DEFAULT_LUMA_BLEND = 60 # 0.60
530
+
531
+ DEFAULT_CURVES_ON = False
532
+ DEFAULT_CURVES_STRENGTH = 20 # 0.20
533
+
534
+ # Avoid cascading signal storms while we set everything
535
+ widgets = [
536
+ self.spin_target,
537
+ self.chk_linked,
538
+ self.chk_normalize,
539
+ self.sld_bp,
540
+ self.chk_no_black_clip,
541
+ self.chk_hdr,
542
+ self.sld_hdr_amt,
543
+ self.sld_hdr_knee,
544
+ self.chk_luma_only,
545
+ self.cmb_luma,
546
+ self.sld_luma_blend,
547
+ self.chk_curves,
548
+ self.sld_curves,
549
+ ]
550
+
551
+ old_blocks = []
552
+ for w in widgets:
553
+ try:
554
+ old_blocks.append((w, w.blockSignals(True)))
555
+ except Exception:
556
+ pass
557
+
558
+ try:
559
+ # Reset “user locked” HDR knee behavior
560
+ self._hdr_knee_user_locked = False
561
+
562
+ # Core controls
563
+ self.spin_target.setValue(DEFAULT_TARGET)
564
+ self.chk_linked.setChecked(DEFAULT_LINKED)
565
+ self.chk_normalize.setChecked(DEFAULT_NORMALIZE)
566
+
567
+ # Black point
568
+ self.chk_no_black_clip.setChecked(DEFAULT_NO_BLACK_CLIP)
569
+ self.sld_bp.setValue(DEFAULT_BP_SLIDER)
570
+ self.lbl_bp.setText(f"{DEFAULT_BP_SLIDER/100:.2f}")
571
+
572
+ # HDR
573
+ self.chk_hdr.setChecked(DEFAULT_HDR_ON)
574
+ self.sld_hdr_amt.setValue(DEFAULT_HDR_AMT)
575
+ self.lbl_hdr_amt.setText(f"{DEFAULT_HDR_AMT/100:.2f}")
576
+ self.sld_hdr_knee.setValue(DEFAULT_HDR_KNEE)
577
+ self.lbl_hdr_knee.setText(f"{DEFAULT_HDR_KNEE/100:.2f}")
578
+
579
+ # Luma-only + mode + blend
580
+ self.chk_luma_only.setChecked(DEFAULT_LUMA_ONLY)
581
+ if DEFAULT_LUMA_MODE:
582
+ self.cmb_luma.setCurrentText(DEFAULT_LUMA_MODE)
583
+ self.sld_luma_blend.setValue(DEFAULT_LUMA_BLEND)
584
+ self.lbl_luma_blend.setText(f"{DEFAULT_LUMA_BLEND/100:.2f}")
585
+
586
+ # Curves
587
+ self.chk_curves.setChecked(DEFAULT_CURVES_ON)
588
+ self.sld_curves.setValue(DEFAULT_CURVES_STRENGTH)
589
+ self.lbl_curves_val.setText(f"{DEFAULT_CURVES_STRENGTH/100:.2f}")
590
+
591
+ finally:
592
+ # Restore signal states
593
+ for w, _prev in old_blocks:
594
+ try:
595
+ w.blockSignals(False)
596
+ except Exception:
597
+ pass
598
+
599
+ # Re-apply dependent enable/disable states exactly like normal interactions
600
+ try:
601
+ # no-black-clip disables BP row
602
+ self.row_bp.setEnabled(not self.chk_no_black_clip.isChecked())
603
+ except Exception:
604
+ pass
605
+
606
+ try:
607
+ # HDR enables HDR row
608
+ self.hdr_row.setEnabled(self.chk_hdr.isChecked())
609
+ except Exception:
610
+ pass
611
+
612
+ try:
613
+ # Curves enables curves row
614
+ self.curves_row.setEnabled(self.chk_curves.isChecked())
615
+ except Exception:
616
+ pass
617
+
618
+ try:
619
+ # Luma-only enables dropdown + blend row, disables linked
620
+ luma_on = self.chk_luma_only.isChecked()
621
+ self.cmb_luma.setEnabled(luma_on)
622
+ self.luma_blend_row.setEnabled(luma_on)
623
+ self.chk_linked.setEnabled(not luma_on)
624
+ except Exception:
625
+ pass
626
+
627
+ # Auto-suggest HDR knee from target (since we cleared lock)
628
+ try:
629
+ t = float(self.spin_target.value())
630
+ knee = float(np.clip(t + 0.10, 0.10, 0.95))
631
+ self.sld_hdr_knee.setValue(int(round(knee * 100)))
632
+ self.lbl_hdr_knee.setText(f"{knee:.2f}")
633
+ except Exception:
634
+ pass
635
+
636
+ # Refresh baseline preview + clip stats
637
+ self._populate_initial_preview()
638
+
639
+
457
640
  def _clip_mode_label(self, imgf: np.ndarray) -> str:
458
641
  # Mono image
459
642
  if imgf.ndim == 2 or (imgf.ndim == 3 and imgf.shape[2] == 1):
@@ -590,7 +773,8 @@ class StatisticalStretchDialog(QDialog):
590
773
  self._job_mode = mode
591
774
 
592
775
  self._set_controls_enabled(False)
593
- self._show_busy("Statistical Stretch", "Processing…")
776
+ self._show_busy("Statistical Stretch", "Processing preview…" if mode == "preview" else "Applying stretch…")
777
+
594
778
 
595
779
  self._thread = QThread(self._main)
596
780
  self._worker = _StretchWorker(self)
@@ -761,6 +945,7 @@ class StatisticalStretchDialog(QDialog):
761
945
  luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
762
946
  luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
763
947
  no_black_clip = bool(self.chk_no_black_clip.isChecked())
948
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
764
949
 
765
950
  target = float(self.spin_target.value())
766
951
  linked = bool(self.chk_linked.isChecked())
@@ -796,6 +981,7 @@ class StatisticalStretchDialog(QDialog):
796
981
  hdr_knee=hdr_knee,
797
982
  luma_only=luma_only,
798
983
  luma_mode=luma_mode,
984
+ luma_blend=luma_blend, # <-- NEW
799
985
  )
800
986
 
801
987
  # ✅ If a mask is active, blend stretched result with original
@@ -895,6 +1081,7 @@ class StatisticalStretchDialog(QDialog):
895
1081
  luma_only = bool(getattr(self, "chk_luma_only", None) and self.chk_luma_only.isChecked())
896
1082
  luma_mode = str(self.cmb_luma.currentText()) if getattr(self, "cmb_luma", None) else "rec709"
897
1083
  no_black_clip = bool(self.chk_no_black_clip.isChecked())
1084
+ luma_blend = float(self.sld_luma_blend.value()) / 100.0 if getattr(self, "sld_luma_blend", None) else 1.0
898
1085
 
899
1086
  parts = [f"target={target:.2f}", "linked" if linked else "unlinked"]
900
1087
  if normalize:
@@ -908,6 +1095,7 @@ class StatisticalStretchDialog(QDialog):
908
1095
  parts.append(f"hdr={hdr_amount:.2f}@{hdr_knee:.2f}")
909
1096
  if luma_only:
910
1097
  parts.append(f"luma={luma_mode}")
1098
+ parts.append(f"blend={luma_blend:.2f}")
911
1099
  if no_black_clip:
912
1100
  parts.append("no_black_clip")
913
1101
 
@@ -952,6 +1140,7 @@ class StatisticalStretchDialog(QDialog):
952
1140
  "hdr_knee": hdr_knee,
953
1141
  "luma_only": luma_only,
954
1142
  "luma_mode": luma_mode,
1143
+ "luma_blend": luma_blend,
955
1144
  }
956
1145
 
957
1146
  # ✅ Remember this as the last headless-style command
@@ -590,6 +590,7 @@ class ImageSubWindow(QWidget):
590
590
  self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
591
591
  self.customContextMenuRequested.connect(self._show_ctx_menu)
592
592
  QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
593
+ QShortcut(QKeySequence("F3"), self, activated=self._rename_document)
593
594
  #QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
594
595
  QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
595
596
  QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
@@ -1726,7 +1727,7 @@ class ImageSubWindow(QWidget):
1726
1727
  def _show_ctx_menu(self, pos):
1727
1728
  menu = QMenu(self)
1728
1729
  a_view = menu.addAction(self.tr("Rename View… (F2)"))
1729
- a_doc = menu.addAction(self.tr("Rename Document…"))
1730
+ a_doc = menu.addAction(self.tr("Rename Document… (F3)"))
1730
1731
  menu.addSeparator()
1731
1732
  a_min = menu.addAction(self.tr("Send to Shelf"))
1732
1733
  a_clear = menu.addAction(self.tr("Clear View Name (use doc name)"))
@@ -3081,7 +3082,6 @@ class ImageSubWindow(QWidget):
3081
3082
  yi = int(round(py / s))
3082
3083
  return xi, yi
3083
3084
 
3084
-
3085
3085
  def _finish_preview_rect(self, vp_rect: QRect):
3086
3086
  if vp_rect.width() < 4 or vp_rect.height() < 4:
3087
3087
  self._cancel_rubber()
@@ -3187,8 +3187,6 @@ class ImageSubWindow(QWidget):
3187
3187
 
3188
3188
  super().mousePressEvent(e)
3189
3189
 
3190
-
3191
-
3192
3190
  def _show_readout(self, xi, yi, sample):
3193
3191
  mw = self._find_main_window()
3194
3192
  if mw is None:
@@ -184,6 +184,30 @@ def apply_white_balance_to_doc(doc, preset: Optional[Dict] = None):
184
184
  step_name="White Balance",
185
185
  )
186
186
 
187
+ def apply_pivot_gain(img: np.ndarray, med: np.ndarray, gains: np.ndarray) -> np.ndarray:
188
+ # img: HxWx3 float32 in [0,1]
189
+ med3 = med.reshape(1, 1, 3).astype(np.float32)
190
+ g3 = gains.reshape(1, 1, 3).astype(np.float32)
191
+
192
+ # pivot around median; do not scale negative deltas
193
+ d = img - med3
194
+ d = np.maximum(d, 0.0)
195
+ out = d * g3 + med3
196
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
197
+
198
+ def smoothstep(edge0, edge1, x):
199
+ t = np.clip((x - edge0) / (edge1 - edge0 + 1e-12), 0.0, 1.0)
200
+ return t * t * (3.0 - 2.0 * t)
201
+
202
+ def apply_soft_protect(img: np.ndarray, out_pivot: np.ndarray, k: float = 0.02) -> np.ndarray:
203
+ # luminance-based fade-in above median luminance
204
+ L = 0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]
205
+ Lm = float(np.median(L))
206
+ w = smoothstep(Lm, Lm + k, L).astype(np.float32)
207
+ w3 = w[..., None]
208
+ out = img * (1.0 - w3) + out_pivot * w3
209
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
210
+
187
211
 
188
212
  # -------------------------
189
213
  # Interactive dialog (UI)