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.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# pro/bundles.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ViewBundle:
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
doc_uids: List[str] = field(default_factory=list) # store stable doc UIDs
|
|
12
|
+
|
|
13
|
+
class BundleManager(QObject):
|
|
14
|
+
changed = pyqtSignal()
|
|
15
|
+
def __init__(self, dm):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.dm = dm
|
|
18
|
+
self._by_id: Dict[str, ViewBundle] = {}
|
|
19
|
+
|
|
20
|
+
def add(self, b: ViewBundle):
|
|
21
|
+
self._by_id[b.id] = b
|
|
22
|
+
self.changed.emit()
|
|
23
|
+
|
|
24
|
+
def remove(self, bid: str):
|
|
25
|
+
self._by_id.pop(bid, None)
|
|
26
|
+
self.changed.emit()
|
|
27
|
+
|
|
28
|
+
def get(self, bid: str) -> Optional[ViewBundle]:
|
|
29
|
+
return self._by_id.get(bid)
|
|
30
|
+
|
|
31
|
+
def all(self) -> list[ViewBundle]:
|
|
32
|
+
return list(self._by_id.values())
|
|
33
|
+
|
|
34
|
+
def add_doc(self, bid: str, doc_uid: str):
|
|
35
|
+
b = self._by_id.get(bid)
|
|
36
|
+
if not b: return
|
|
37
|
+
if doc_uid not in b.doc_uids:
|
|
38
|
+
b.doc_uids.append(doc_uid)
|
|
39
|
+
self.changed.emit()
|
|
40
|
+
|
|
41
|
+
def remove_doc(self, bid: str, doc_uid: str):
|
|
42
|
+
b = self._by_id.get(bid)
|
|
43
|
+
if not b: return
|
|
44
|
+
if doc_uid in b.doc_uids:
|
|
45
|
+
b.doc_uids.remove(doc_uid)
|
|
46
|
+
self.changed.emit()
|
|
47
|
+
|
|
48
|
+
def docs(self, bid: str):
|
|
49
|
+
b = self._by_id.get(bid)
|
|
50
|
+
if not b: return []
|
|
51
|
+
out = []
|
|
52
|
+
for uid in b.doc_uids:
|
|
53
|
+
d = getattr(self.dm, "find_document_by_uid", None)
|
|
54
|
+
if callable(d):
|
|
55
|
+
doc = d(uid)
|
|
56
|
+
else:
|
|
57
|
+
# fallback: linear scan over open docs if you don't have an index
|
|
58
|
+
doc = next((x for x in getattr(self.dm, "documents", []) if getattr(x, "uid", None) == uid), None)
|
|
59
|
+
if doc:
|
|
60
|
+
out.append(doc)
|
|
61
|
+
return out
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# pro/bundles_dock.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from PyQt6.QtWidgets import QDockWidget, QListWidget, QListWidgetItem, QMenu, QInputDialog
|
|
4
|
+
from PyQt6.QtCore import Qt, QMimeData, QByteArray
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from setiastro.saspro.dnd_mime import MIME_CMD # you already use this
|
|
8
|
+
|
|
9
|
+
class BundlesDock(QDockWidget):
|
|
10
|
+
def __init__(self, mw, bm, pipelines):
|
|
11
|
+
super().__init__("Bundles", mw)
|
|
12
|
+
self.mw = mw
|
|
13
|
+
self.bm = bm
|
|
14
|
+
self.pipelines = pipelines
|
|
15
|
+
|
|
16
|
+
self.list = QListWidget(self)
|
|
17
|
+
self.list.setSelectionMode(self.list.SelectionMode.SingleSelection)
|
|
18
|
+
self.list.setAcceptDrops(True)
|
|
19
|
+
self.list.setDragEnabled(False)
|
|
20
|
+
self.setWidget(self.list)
|
|
21
|
+
|
|
22
|
+
self.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
|
|
23
|
+
self._refresh()
|
|
24
|
+
bm.changed.connect(self._refresh)
|
|
25
|
+
|
|
26
|
+
self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
27
|
+
self.list.customContextMenuRequested.connect(self._ctx)
|
|
28
|
+
|
|
29
|
+
def _refresh(self):
|
|
30
|
+
self.list.clear()
|
|
31
|
+
for b in self.bm.all():
|
|
32
|
+
it = QListWidgetItem(f"{b.name} ({len(b.doc_uids)} views)")
|
|
33
|
+
it.setData(Qt.ItemDataRole.UserRole, b.id)
|
|
34
|
+
self.list.addItem(it)
|
|
35
|
+
|
|
36
|
+
def _ctx(self, pos):
|
|
37
|
+
it = self.list.itemAt(pos)
|
|
38
|
+
m = QMenu(self)
|
|
39
|
+
m.addAction("New Bundle…", self._new_bundle)
|
|
40
|
+
if it:
|
|
41
|
+
bid = it.data(Qt.ItemDataRole.UserRole)
|
|
42
|
+
m.addSeparator()
|
|
43
|
+
m.addAction("Rename…", lambda: self._rename(bid))
|
|
44
|
+
m.addAction("Delete", lambda: self._delete(bid))
|
|
45
|
+
m.addSeparator()
|
|
46
|
+
m.addAction("Run Pipeline…", lambda: self._pick_and_run(bid))
|
|
47
|
+
m.exec(self.list.mapToGlobal(pos))
|
|
48
|
+
|
|
49
|
+
def _new_bundle(self):
|
|
50
|
+
name, ok = QInputDialog.getText(self, "New Bundle", "Name:")
|
|
51
|
+
if not ok or not name.strip(): return
|
|
52
|
+
import uuid
|
|
53
|
+
from setiastro.saspro.bundles import ViewBundle
|
|
54
|
+
self.bm.add(ViewBundle(id=uuid.uuid4().hex, name=name.strip()))
|
|
55
|
+
|
|
56
|
+
def _rename(self, bid):
|
|
57
|
+
b = self.bm.get(bid);
|
|
58
|
+
if not b: return
|
|
59
|
+
name, ok = QInputDialog.getText(self, "Rename Bundle", "Name:", text=b.name)
|
|
60
|
+
if ok and name.strip():
|
|
61
|
+
b.name = name.strip()
|
|
62
|
+
self.bm.changed.emit()
|
|
63
|
+
|
|
64
|
+
def _delete(self, bid):
|
|
65
|
+
self.bm.remove(bid)
|
|
66
|
+
|
|
67
|
+
# --- DnD: add docs or run commands ---
|
|
68
|
+
def dragEnterEvent(self, e):
|
|
69
|
+
if e.mimeData().hasFormat(MIME_CMD):
|
|
70
|
+
e.acceptProposedAction(); return
|
|
71
|
+
# Let your explorer/subwindow provide a doc UID mime if you have one; example below:
|
|
72
|
+
if e.mimeData().hasFormat("application/x-saspro-doc-uid"):
|
|
73
|
+
e.acceptProposedAction(); return
|
|
74
|
+
e.ignore()
|
|
75
|
+
|
|
76
|
+
def dropEvent(self, e):
|
|
77
|
+
it = self.list.itemAt(e.position().toPoint())
|
|
78
|
+
if not it:
|
|
79
|
+
e.ignore(); return
|
|
80
|
+
bid = it.data(Qt.ItemDataRole.UserRole)
|
|
81
|
+
|
|
82
|
+
md: QMimeData = e.mimeData()
|
|
83
|
+
# 1) drop a command/pipeline onto a bundle -> run across all docs in the bundle
|
|
84
|
+
if md.hasFormat(MIME_CMD):
|
|
85
|
+
try:
|
|
86
|
+
payload = json.loads(bytes(md.data(MIME_CMD)).decode("utf-8"))
|
|
87
|
+
except Exception:
|
|
88
|
+
e.ignore(); return
|
|
89
|
+
docs = self.bm.docs(bid)
|
|
90
|
+
if not docs:
|
|
91
|
+
e.ignore(); return
|
|
92
|
+
# Pipeline support via "pipeline:<id>" (see MW patch below)
|
|
93
|
+
self.mw._run_payload_on_docs(payload, docs)
|
|
94
|
+
e.acceptProposedAction(); return
|
|
95
|
+
|
|
96
|
+
# 2) drop doc(s) onto a bundle -> add
|
|
97
|
+
if md.hasFormat("application/x-saspro-doc-uid"):
|
|
98
|
+
uid = bytes(md.data("application/x-saspro-doc-uid")).decode("utf-8")
|
|
99
|
+
self.bm.add_doc(bid, uid)
|
|
100
|
+
e.acceptProposedAction(); return
|
|
101
|
+
|
|
102
|
+
e.ignore()
|
|
103
|
+
|
|
104
|
+
# quick picker to run a pipeline without DnD
|
|
105
|
+
def _pick_and_run(self, bid):
|
|
106
|
+
plist = self.pipelines.all()
|
|
107
|
+
if not plist:
|
|
108
|
+
return
|
|
109
|
+
names = [p.name for p in plist]
|
|
110
|
+
i, ok = QInputDialog.getItem(self, "Run Pipeline", "Pick:", names, 0, False)
|
|
111
|
+
if not ok: return
|
|
112
|
+
p = plist[names.index(i)]
|
|
113
|
+
payload = {"command_id": f"pipeline:{p.id}", "preset": {}}
|
|
114
|
+
self.mw._run_payload_on_docs(payload, self.bm.docs(bid))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# pro/cheat_sheet.py
|
|
2
|
+
"""
|
|
3
|
+
Keyboard shortcut cheat sheet dialog.
|
|
4
|
+
|
|
5
|
+
Displays all keyboard shortcuts and mouse gestures in a tabbed dialog.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
|
10
|
+
QTableWidget, QTableWidgetItem, QHeaderView, QPushButton,
|
|
11
|
+
QApplication, QMessageBox
|
|
12
|
+
)
|
|
13
|
+
from PyQt6.QtGui import QAction, QShortcut, QKeySequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _qs_to_str(seq: QKeySequence) -> str:
|
|
17
|
+
"""Convert a QKeySequence to a human-readable string."""
|
|
18
|
+
return seq.toString(QKeySequence.SequenceFormat.NativeText).strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _clean_text(text: str) -> str:
|
|
22
|
+
"""Remove common Unicode decoration from text."""
|
|
23
|
+
if not text:
|
|
24
|
+
return ""
|
|
25
|
+
# Remove common ellipsis, arrows, etc.
|
|
26
|
+
return text.replace("…", "").replace("→", "->").replace("←", "<-").strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _uniq_keep_order(items):
|
|
30
|
+
"""Return unique items preserving order."""
|
|
31
|
+
seen = set()
|
|
32
|
+
out = []
|
|
33
|
+
for x in items:
|
|
34
|
+
if x in seen:
|
|
35
|
+
continue
|
|
36
|
+
seen.add(x)
|
|
37
|
+
out.append(x)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _seqs_for_action(act: QAction):
|
|
42
|
+
"""Get non-empty key sequences for an action."""
|
|
43
|
+
seqs = [s for s in act.shortcuts() or []] or ([act.shortcut()] if act.shortcut() else [])
|
|
44
|
+
return [s for s in seqs if not s.isEmpty()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _where_for_action(act: QAction) -> str:
|
|
48
|
+
"""Determine where an action is available (Menus/Toolbar or Window)."""
|
|
49
|
+
if act.parent():
|
|
50
|
+
pn = act.parent().__class__.__name__
|
|
51
|
+
if pn.startswith("QMenu") or pn.startswith("QToolBar"):
|
|
52
|
+
return "Menus/Toolbar"
|
|
53
|
+
return "Window"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _describe_action(act: QAction) -> str:
|
|
57
|
+
"""Get a human-readable description for an action."""
|
|
58
|
+
return _clean_text(act.statusTip() or act.toolTip() or act.text() or act.objectName() or "Action")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _describe_shortcut(sc: QShortcut) -> str:
|
|
62
|
+
"""Get a human-readable description for a shortcut."""
|
|
63
|
+
return _clean_text(sc.property("hint") or sc.whatsThis() or sc.objectName() or "Shortcut")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _where_for_shortcut(sc: QShortcut) -> str:
|
|
67
|
+
"""Determine where a shortcut is available."""
|
|
68
|
+
par = sc.parent()
|
|
69
|
+
return par.__class__.__name__ if par is not None else "Window"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CheatSheetDialog(QDialog):
|
|
73
|
+
"""
|
|
74
|
+
Dialog showing all keyboard shortcuts and mouse gestures.
|
|
75
|
+
|
|
76
|
+
Displays two tabs:
|
|
77
|
+
- Keyboard shortcuts (from QActions)
|
|
78
|
+
- Mouse/drag gestures
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, parent, keyboard_rows, gesture_rows):
|
|
82
|
+
super().__init__(parent)
|
|
83
|
+
self.setWindowTitle("Keyboard Shortcut Cheat Sheet")
|
|
84
|
+
self.resize(780, 520)
|
|
85
|
+
|
|
86
|
+
self._keyboard_rows = keyboard_rows
|
|
87
|
+
self._gesture_rows = gesture_rows
|
|
88
|
+
|
|
89
|
+
tabs = QTabWidget(self)
|
|
90
|
+
|
|
91
|
+
# --- Keyboard tab ---
|
|
92
|
+
pg_keys = QWidget(tabs)
|
|
93
|
+
v1 = QVBoxLayout(pg_keys)
|
|
94
|
+
tbl_keys = QTableWidget(0, 3, pg_keys)
|
|
95
|
+
tbl_keys.setHorizontalHeaderLabels(["Shortcut", "Action", "Where"])
|
|
96
|
+
tbl_keys.verticalHeader().setVisible(False)
|
|
97
|
+
tbl_keys.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
98
|
+
tbl_keys.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
99
|
+
tbl_keys.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
|
100
|
+
tbl_keys.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
101
|
+
tbl_keys.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
102
|
+
tbl_keys.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
103
|
+
v1.addWidget(tbl_keys)
|
|
104
|
+
|
|
105
|
+
# Populate keyboard shortcuts
|
|
106
|
+
for s, action, where in keyboard_rows:
|
|
107
|
+
r = tbl_keys.rowCount()
|
|
108
|
+
tbl_keys.insertRow(r)
|
|
109
|
+
tbl_keys.setItem(r, 0, QTableWidgetItem(s))
|
|
110
|
+
tbl_keys.setItem(r, 1, QTableWidgetItem(action))
|
|
111
|
+
tbl_keys.setItem(r, 2, QTableWidgetItem(where))
|
|
112
|
+
|
|
113
|
+
# --- Mouse/Drag tab ---
|
|
114
|
+
pg_mouse = QWidget(tabs)
|
|
115
|
+
v2 = QVBoxLayout(pg_mouse)
|
|
116
|
+
tbl_mouse = QTableWidget(0, 3, pg_mouse)
|
|
117
|
+
tbl_mouse.setHorizontalHeaderLabels(["Gesture", "Context", "Effect"])
|
|
118
|
+
tbl_mouse.verticalHeader().setVisible(False)
|
|
119
|
+
tbl_mouse.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
120
|
+
tbl_mouse.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
121
|
+
tbl_mouse.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
|
122
|
+
tbl_mouse.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
|
123
|
+
tbl_mouse.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
124
|
+
tbl_mouse.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
|
125
|
+
v2.addWidget(tbl_mouse)
|
|
126
|
+
|
|
127
|
+
# Populate mouse gestures
|
|
128
|
+
for gesture, context, effect in gesture_rows:
|
|
129
|
+
r = tbl_mouse.rowCount()
|
|
130
|
+
tbl_mouse.insertRow(r)
|
|
131
|
+
tbl_mouse.setItem(r, 0, QTableWidgetItem(gesture))
|
|
132
|
+
tbl_mouse.setItem(r, 1, QTableWidgetItem(context))
|
|
133
|
+
tbl_mouse.setItem(r, 2, QTableWidgetItem(effect))
|
|
134
|
+
|
|
135
|
+
tabs.addTab(pg_keys, "Base Keyboard")
|
|
136
|
+
tabs.addTab(pg_mouse, "Additional & Mouse & Drag")
|
|
137
|
+
|
|
138
|
+
# Buttons
|
|
139
|
+
btns = QHBoxLayout()
|
|
140
|
+
btns.addStretch(1)
|
|
141
|
+
|
|
142
|
+
b_copy = QPushButton("Copy")
|
|
143
|
+
b_copy.clicked.connect(self._copy_all)
|
|
144
|
+
b_close = QPushButton("Close")
|
|
145
|
+
b_close.clicked.connect(self.accept)
|
|
146
|
+
btns.addWidget(b_copy)
|
|
147
|
+
btns.addWidget(b_close)
|
|
148
|
+
|
|
149
|
+
top = QVBoxLayout(self)
|
|
150
|
+
top.addWidget(tabs)
|
|
151
|
+
top.addLayout(btns)
|
|
152
|
+
|
|
153
|
+
def _copy_all(self):
|
|
154
|
+
"""Copy all shortcuts to clipboard as plain text."""
|
|
155
|
+
lines = []
|
|
156
|
+
lines.append("== Keyboard ==")
|
|
157
|
+
for s, a, w in self._keyboard_rows:
|
|
158
|
+
lines.append(f"{s:20} {a} [{w}]")
|
|
159
|
+
lines.append("")
|
|
160
|
+
lines.append("== Mouse & Drag ==")
|
|
161
|
+
for g, c, e in self._gesture_rows:
|
|
162
|
+
lines.append(f"{g:24} {c:18} {e}")
|
|
163
|
+
QApplication.clipboard().setText("\n".join(lines))
|
|
164
|
+
QMessageBox.information(self, "Copied", "Cheat sheet copied to clipboard.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Legacy alias for backward compatibility
|
|
168
|
+
_CheatSheetDialog = CheatSheetDialog
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# pro/clahe.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
7
|
+
from PyQt6.QtGui import QImage, QPixmap, QIcon
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout,
|
|
10
|
+
QLabel, QPushButton, QSlider, QGraphicsScene,
|
|
11
|
+
QGraphicsPixmapItem, QMessageBox
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Import centralized widgets
|
|
15
|
+
from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
|
|
16
|
+
from setiastro.saspro.widgets.image_utils import extract_mask_resized as _get_active_mask_resized
|
|
17
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ----------------------- Core -----------------------
|
|
21
|
+
def apply_clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid_size: tuple = (8, 8)) -> np.ndarray:
|
|
22
|
+
# ... (unchanged)
|
|
23
|
+
if image is None:
|
|
24
|
+
raise ValueError("image is None")
|
|
25
|
+
arr = np.asarray(image, dtype=np.float32)
|
|
26
|
+
arr = np.clip(arr, 0.0, 1.0)
|
|
27
|
+
was_hw1 = (arr.ndim == 3 and arr.shape[2] == 1)
|
|
28
|
+
if arr.ndim == 3 and arr.shape[2] == 3:
|
|
29
|
+
lab = cv2.cvtColor((arr * 255.0).astype(np.uint8), cv2.COLOR_RGB2LAB)
|
|
30
|
+
l, a, b = cv2.split(lab)
|
|
31
|
+
clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid_size))
|
|
32
|
+
cl = clahe.apply(l)
|
|
33
|
+
limg = cv2.merge((cl, a, b))
|
|
34
|
+
enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2RGB).astype(np.float32) / 255.0
|
|
35
|
+
return np.clip(enhanced, 0.0, 1.0)
|
|
36
|
+
mono = arr.squeeze()
|
|
37
|
+
clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid_size))
|
|
38
|
+
cl = clahe.apply((mono * 255.0).astype(np.uint8)).astype(np.float32) / 255.0
|
|
39
|
+
cl = np.clip(cl, 0.0, 1.0)
|
|
40
|
+
if was_hw1:
|
|
41
|
+
cl = cl[..., None]
|
|
42
|
+
return cl
|
|
43
|
+
|
|
44
|
+
# Note: _get_active_mask_resized imported from setiastro.saspro.widgets.image_utils
|
|
45
|
+
|
|
46
|
+
def apply_clahe_to_doc(doc, preset: dict | None):
|
|
47
|
+
"""
|
|
48
|
+
Apply CLAHE to doc.image using a preset.
|
|
49
|
+
|
|
50
|
+
Backward compatible:
|
|
51
|
+
- old presets: {"clip_limit": 2.0, "tile": 8} # tile count across min dimension
|
|
52
|
+
- new presets: {"clip_limit": 2.0, "tile_px": 128} # tile size in pixels
|
|
53
|
+
"""
|
|
54
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
55
|
+
raise RuntimeError("Document has no image.")
|
|
56
|
+
|
|
57
|
+
img = np.asarray(doc.image)
|
|
58
|
+
|
|
59
|
+
# --- preset decode (supports old + new) ---
|
|
60
|
+
p = preset or {}
|
|
61
|
+
clip = float(p.get("clip_limit", 2.0))
|
|
62
|
+
|
|
63
|
+
# Resolve tile_grid_size for OpenCV
|
|
64
|
+
if "tile_px" in p:
|
|
65
|
+
tile_px = int(p.get("tile_px", 128))
|
|
66
|
+
h, w = img.shape[:2]
|
|
67
|
+
s = float(min(h, w))
|
|
68
|
+
tile_px = max(8, tile_px)
|
|
69
|
+
n = int(round(s / float(tile_px)))
|
|
70
|
+
n = max(2, min(n, 128))
|
|
71
|
+
tile_grid = (n, n)
|
|
72
|
+
else:
|
|
73
|
+
# legacy: treat "tile" as OpenCV tileGridSize count (tiles across)
|
|
74
|
+
tile = int(p.get("tile", 8))
|
|
75
|
+
tile = max(2, min(tile, 128))
|
|
76
|
+
tile_grid = (tile, tile)
|
|
77
|
+
|
|
78
|
+
out = apply_clahe(img, clip_limit=clip, tile_grid_size=tile_grid)
|
|
79
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
80
|
+
|
|
81
|
+
# Blend with active mask if present
|
|
82
|
+
H, W = out.shape[:2]
|
|
83
|
+
m = _get_active_mask_resized(doc, H, W)
|
|
84
|
+
if m is not None:
|
|
85
|
+
base = np.asarray(doc.image, dtype=np.float32)
|
|
86
|
+
if base.dtype.kind in "ui":
|
|
87
|
+
maxv = float(np.iinfo(base.dtype).max)
|
|
88
|
+
base = base / max(1.0, maxv)
|
|
89
|
+
else:
|
|
90
|
+
base = np.clip(base, 0.0, 1.0)
|
|
91
|
+
|
|
92
|
+
if out.ndim == 3:
|
|
93
|
+
if base.ndim == 2:
|
|
94
|
+
base = base[:, :, None].repeat(out.shape[2], axis=2)
|
|
95
|
+
elif base.ndim == 3 and base.shape[2] == 1:
|
|
96
|
+
base = base.repeat(out.shape[2], axis=2)
|
|
97
|
+
M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
|
|
98
|
+
out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
|
|
99
|
+
else:
|
|
100
|
+
if base.ndim == 3 and base.shape[2] == 1:
|
|
101
|
+
base = base.squeeze(axis=2)
|
|
102
|
+
out = np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
|
|
103
|
+
|
|
104
|
+
# Commit
|
|
105
|
+
if hasattr(doc, "set_image"):
|
|
106
|
+
doc.set_image(out, step_name="CLAHE")
|
|
107
|
+
elif hasattr(doc, "apply_numpy"):
|
|
108
|
+
doc.apply_numpy(out, step_name="CLAHE")
|
|
109
|
+
else:
|
|
110
|
+
doc.image = out
|
|
111
|
+
|
|
112
|
+
# ----------------------- Dialog -----------------------
|
|
113
|
+
class CLAHEDialogPro(QDialog):
|
|
114
|
+
def __init__(self, parent, doc, icon: QIcon | None = None):
|
|
115
|
+
super().__init__(parent)
|
|
116
|
+
self.setWindowTitle("CLAHE")
|
|
117
|
+
if icon:
|
|
118
|
+
try: self.setWindowIcon(icon)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
import logging
|
|
121
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
122
|
+
|
|
123
|
+
self.doc = doc
|
|
124
|
+
self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
|
|
125
|
+
disp = self.orig
|
|
126
|
+
if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
|
|
127
|
+
elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
|
|
128
|
+
self._disp_base = disp
|
|
129
|
+
|
|
130
|
+
v = QVBoxLayout(self)
|
|
131
|
+
|
|
132
|
+
# ---- Params (unchanged) ----
|
|
133
|
+
grp = QGroupBox("CLAHE Parameters"); grid = QGridLayout(grp)
|
|
134
|
+
self.s_clip = QSlider(Qt.Orientation.Horizontal); self.s_clip.setRange(1, 40); self.s_clip.setValue(20)
|
|
135
|
+
self.lbl_clip = QLabel("2.0")
|
|
136
|
+
self.s_clip.valueChanged.connect(lambda val: self.lbl_clip.setText(f"{val/10.0:.1f}"))
|
|
137
|
+
self.s_clip.valueChanged.connect(self._debounce_preview)
|
|
138
|
+
|
|
139
|
+
# tile size slider (pixels) — intuitive control
|
|
140
|
+
self.s_tile = QSlider(Qt.Orientation.Horizontal)
|
|
141
|
+
self.s_tile.setRange(8, 512) # 4 is pointless; you clamp to >=8 anyway
|
|
142
|
+
self.s_tile.setSingleStep(8)
|
|
143
|
+
self.s_tile.setPageStep(64)
|
|
144
|
+
self.s_tile.setValue(128) # nice default
|
|
145
|
+
self.s_tile.setToolTip("CLAHE tile size in pixels (larger = coarser, smaller = finer).")
|
|
146
|
+
|
|
147
|
+
self.lbl_tile = QLabel("128 px")
|
|
148
|
+
self.lbl_tile.setToolTip(self.s_tile.toolTip())
|
|
149
|
+
|
|
150
|
+
self.s_tile.valueChanged.connect(lambda v: self.lbl_tile.setText(f"{v} px"))
|
|
151
|
+
self.s_tile.valueChanged.connect(self._debounce_preview)
|
|
152
|
+
|
|
153
|
+
grid.addWidget(QLabel("Tile Size (px):"), 1, 0)
|
|
154
|
+
grid.addWidget(self.s_tile, 1, 1)
|
|
155
|
+
grid.addWidget(self.lbl_tile, 1, 2)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
grid.addWidget(QLabel("Clip Limit:"), 0, 0); grid.addWidget(self.s_clip, 0, 1); grid.addWidget(self.lbl_clip, 0, 2)
|
|
160
|
+
|
|
161
|
+
v.addWidget(grp)
|
|
162
|
+
|
|
163
|
+
# ---- Preview with zoom/pan ----
|
|
164
|
+
self.scene = QGraphicsScene(self)
|
|
165
|
+
self.view = ZoomableGraphicsView(self.scene)
|
|
166
|
+
self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
167
|
+
self.pix = QGraphicsPixmapItem()
|
|
168
|
+
self.scene.addItem(self.pix)
|
|
169
|
+
v.addWidget(self.view, 1)
|
|
170
|
+
|
|
171
|
+
# ---- Zoom bar ----
|
|
172
|
+
# ---- Zoom bar (themed) ----
|
|
173
|
+
z = QHBoxLayout()
|
|
174
|
+
z.addStretch(1)
|
|
175
|
+
|
|
176
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
177
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
178
|
+
self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
179
|
+
|
|
180
|
+
self.btn_zoom_in.clicked.connect(self.view.zoom_in)
|
|
181
|
+
self.btn_zoom_out.clicked.connect(self.view.zoom_out)
|
|
182
|
+
self.btn_zoom_fit.clicked.connect(lambda: self.view.fit_to_item(self.pix))
|
|
183
|
+
|
|
184
|
+
z.addWidget(self.btn_zoom_in)
|
|
185
|
+
z.addWidget(self.btn_zoom_out)
|
|
186
|
+
z.addWidget(self.btn_zoom_fit)
|
|
187
|
+
|
|
188
|
+
v.addLayout(z)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---- Buttons (unchanged) ----
|
|
192
|
+
row = QHBoxLayout()
|
|
193
|
+
self.btn_apply = QPushButton("Apply"); self.btn_apply.clicked.connect(self._apply)
|
|
194
|
+
self.btn_reset = QPushButton("Reset"); self.btn_reset.clicked.connect(self._reset)
|
|
195
|
+
self.btn_close = QPushButton("Cancel"); self.btn_close.clicked.connect(self.reject)
|
|
196
|
+
row.addStretch(1); row.addWidget(self.btn_apply); row.addWidget(self.btn_reset); row.addWidget(self.btn_close)
|
|
197
|
+
v.addLayout(row)
|
|
198
|
+
|
|
199
|
+
self._timer = QTimer(self); self._timer.setSingleShot(True); self._timer.timeout.connect(self._update_preview)
|
|
200
|
+
|
|
201
|
+
self._set_pix(self._disp_base)
|
|
202
|
+
self._update_preview()
|
|
203
|
+
# initial fit
|
|
204
|
+
self.view.fit_to_item(self.pix)
|
|
205
|
+
|
|
206
|
+
def _debounce_preview(self): self._timer.start(250)
|
|
207
|
+
|
|
208
|
+
def _set_pix(self, rgb):
|
|
209
|
+
arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
|
|
210
|
+
h, w, _ = arr.shape
|
|
211
|
+
q = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
212
|
+
self.pix.setPixmap(QPixmap.fromImage(q))
|
|
213
|
+
self.scene.setSceneRect(self.pix.boundingRect())
|
|
214
|
+
|
|
215
|
+
def _update_preview(self):
|
|
216
|
+
clip = self.s_clip.value() / 10.0
|
|
217
|
+
tile_px = int(self.s_tile.value())
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
tile_grid = self._tile_grid_from_px(tile_px, self._disp_base.shape[:2])
|
|
221
|
+
|
|
222
|
+
out = apply_clahe(
|
|
223
|
+
self._disp_base,
|
|
224
|
+
clip_limit=float(clip),
|
|
225
|
+
tile_grid_size=tile_grid
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Respect active mask (preview works on _disp_base size)
|
|
229
|
+
H, W = out.shape[:2]
|
|
230
|
+
m = _get_active_mask_resized(self.doc, H, W)
|
|
231
|
+
if m is not None:
|
|
232
|
+
if out.ndim == 3:
|
|
233
|
+
M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
|
|
234
|
+
else:
|
|
235
|
+
M = m.astype(np.float32)
|
|
236
|
+
|
|
237
|
+
base = self._disp_base.astype(np.float32, copy=False)
|
|
238
|
+
out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
|
|
239
|
+
|
|
240
|
+
self._set_pix(out)
|
|
241
|
+
self._preview = out
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
QMessageBox.warning(self, "CLAHE", f"Preview failed:\n{e}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _apply(self):
|
|
248
|
+
try:
|
|
249
|
+
clip = float(self.s_clip.value() / 10.0)
|
|
250
|
+
tile_px = int(self.s_tile.value())
|
|
251
|
+
|
|
252
|
+
tile_grid = self._tile_grid_from_px(tile_px, self.orig.shape[:2])
|
|
253
|
+
|
|
254
|
+
out = apply_clahe(
|
|
255
|
+
self.orig,
|
|
256
|
+
clip_limit=clip,
|
|
257
|
+
tile_grid_size=tile_grid
|
|
258
|
+
)
|
|
259
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
260
|
+
|
|
261
|
+
# Mask-respectful commit
|
|
262
|
+
H, W = out.shape[:2]
|
|
263
|
+
m = _get_active_mask_resized(self.doc, H, W)
|
|
264
|
+
if m is not None:
|
|
265
|
+
base = np.asarray(self.doc.image, dtype=np.float32)
|
|
266
|
+
|
|
267
|
+
# Normalize base into [0..1] for blending
|
|
268
|
+
if base.dtype.kind in "ui":
|
|
269
|
+
maxv = float(np.iinfo(base.dtype).max)
|
|
270
|
+
base = base / max(1.0, maxv)
|
|
271
|
+
else:
|
|
272
|
+
base = np.clip(base, 0.0, 1.0)
|
|
273
|
+
|
|
274
|
+
if out.ndim == 3:
|
|
275
|
+
if base.ndim == 2:
|
|
276
|
+
base = base[:, :, None].repeat(out.shape[2], axis=2)
|
|
277
|
+
elif base.ndim == 3 and base.shape[2] == 1:
|
|
278
|
+
base = base.repeat(out.shape[2], axis=2)
|
|
279
|
+
|
|
280
|
+
M = np.repeat(m[:, :, None], out.shape[2], axis=2).astype(np.float32)
|
|
281
|
+
out = np.clip(base * (1.0 - M) + out * M, 0.0, 1.0)
|
|
282
|
+
else:
|
|
283
|
+
if base.ndim == 3 and base.shape[2] == 1:
|
|
284
|
+
base = base.squeeze(axis=2)
|
|
285
|
+
out = np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
|
|
286
|
+
|
|
287
|
+
out = out.astype(np.float32, copy=False)
|
|
288
|
+
|
|
289
|
+
# Commit to document
|
|
290
|
+
if hasattr(self.doc, "set_image"):
|
|
291
|
+
self.doc.set_image(out, step_name="CLAHE")
|
|
292
|
+
elif hasattr(self.doc, "apply_numpy"):
|
|
293
|
+
self.doc.apply_numpy(out, step_name="CLAHE")
|
|
294
|
+
else:
|
|
295
|
+
self.doc.image = out
|
|
296
|
+
|
|
297
|
+
# ── Register as last_headless_command for replay ─────────────
|
|
298
|
+
try:
|
|
299
|
+
main = self.parent()
|
|
300
|
+
if main is not None:
|
|
301
|
+
preset = {
|
|
302
|
+
"clip_limit": float(clip),
|
|
303
|
+
"tile_px": int(tile_px), # NEW, intuitive
|
|
304
|
+
# (optional debug)
|
|
305
|
+
# "tile": int(tile_grid[0]),
|
|
306
|
+
}
|
|
307
|
+
payload = {"command_id": "clahe", "preset": dict(preset)}
|
|
308
|
+
setattr(main, "_last_headless_command", payload)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
if hasattr(main, "_log"):
|
|
312
|
+
main._log(
|
|
313
|
+
f"[Replay] Registered CLAHE as last action "
|
|
314
|
+
f"(clip_limit={preset['clip_limit']}, tile_px={preset['tile_px']})"
|
|
315
|
+
)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
# ─────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
self.accept()
|
|
323
|
+
|
|
324
|
+
except Exception as e:
|
|
325
|
+
QMessageBox.critical(self, "CLAHE", f"Failed to apply:\n{e}")
|
|
326
|
+
|
|
327
|
+
def _tile_grid_from_px(self, tile_px: int, hw: tuple[int, int]) -> tuple[int, int]:
|
|
328
|
+
"""
|
|
329
|
+
Convert desired tile size (pixels) into OpenCV tileGridSize=(n,n)
|
|
330
|
+
where n is number of tiles across the *min dimension*.
|
|
331
|
+
"""
|
|
332
|
+
h, w = hw
|
|
333
|
+
s = float(min(h, w))
|
|
334
|
+
tile_px = max(8, int(tile_px))
|
|
335
|
+
n = int(round(s / float(tile_px)))
|
|
336
|
+
n = max(2, min(n, 128))
|
|
337
|
+
return (n, n)
|
|
338
|
+
|
|
339
|
+
def _reset(self):
|
|
340
|
+
self.s_clip.setValue(20); self.s_tile.setValue(8)
|
|
341
|
+
self._set_pix(self._disp_base)
|
|
342
|
+
self.view.fit_to_item(self.pix)
|