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.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -5,6 +5,11 @@ import numpy as np
|
|
|
5
5
|
# ---- Try Numba kernels from legacy ----
|
|
6
6
|
try:
|
|
7
7
|
from setiastro.saspro.legacy.numba_utils import (
|
|
8
|
+
numba_mono_from_img,
|
|
9
|
+
numba_color_linked_from_img,
|
|
10
|
+
numba_color_unlinked_from_img,
|
|
11
|
+
|
|
12
|
+
# keep these too if other callers still use them
|
|
8
13
|
numba_mono_final_formula,
|
|
9
14
|
numba_color_final_formula_linked,
|
|
10
15
|
numba_color_final_formula_unlinked,
|
|
@@ -13,32 +18,351 @@ try:
|
|
|
13
18
|
except Exception:
|
|
14
19
|
_HAS_NUMBA = False
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
r = rescaled
|
|
21
|
+
def numba_mono_from_img(img, bp, denom, median_rescaled, target_median):
|
|
22
|
+
r = (img - bp) / denom
|
|
19
23
|
med = float(median_rescaled)
|
|
20
24
|
num = (med - 1.0) * target_median * r
|
|
21
25
|
den = med * (target_median + r - 1.0) - target_median * r
|
|
22
26
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
23
27
|
return num / den
|
|
24
28
|
|
|
25
|
-
def
|
|
26
|
-
r =
|
|
29
|
+
def numba_color_linked_from_img(img, bp, denom, median_rescaled, target_median):
|
|
30
|
+
r = (img - bp) / denom
|
|
27
31
|
med = float(median_rescaled)
|
|
28
32
|
num = (med - 1.0) * target_median * r
|
|
29
33
|
den = med * (target_median + r - 1.0) - target_median * r
|
|
30
34
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
31
35
|
return num / den
|
|
32
36
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
def numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, target_median):
|
|
38
|
+
bp3 = np.asarray(bp3, dtype=np.float32).reshape((1, 1, 3))
|
|
39
|
+
denom3 = np.asarray(denom3, dtype=np.float32).reshape((1, 1, 3))
|
|
40
|
+
meds = np.asarray(meds_rescaled3, dtype=np.float32).reshape((1, 1, 3))
|
|
41
|
+
r = (img - bp3) / denom3
|
|
42
|
+
num = (meds - 1.0) * target_median * r
|
|
43
|
+
den = meds * (target_median + r - 1.0) - target_median * r
|
|
38
44
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
39
45
|
return num / den
|
|
40
46
|
|
|
41
47
|
|
|
48
|
+
from setiastro.saspro.luminancerecombine import (
|
|
49
|
+
LUMA_PROFILES,
|
|
50
|
+
resolve_luma_profile_weights,
|
|
51
|
+
compute_luminance,
|
|
52
|
+
recombine_luminance_linear_scale,
|
|
53
|
+
_estimate_noise_sigma_per_channel, # <-- add this
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _sample_flat(x: np.ndarray, max_n: int = 400_000) -> np.ndarray:
|
|
57
|
+
flat = np.asarray(x, np.float32).reshape(-1)
|
|
58
|
+
n = flat.size
|
|
59
|
+
if n <= max_n:
|
|
60
|
+
return flat
|
|
61
|
+
stride = max(1, n // max_n)
|
|
62
|
+
return flat[::stride]
|
|
63
|
+
|
|
64
|
+
def _robust_sigma_lower_half_fast(x: np.ndarray, max_n: int = 400_000) -> float:
|
|
65
|
+
s = _sample_flat(x, max_n=max_n)
|
|
66
|
+
med = float(np.median(s))
|
|
67
|
+
lo = s[s <= med]
|
|
68
|
+
if lo.size < 16:
|
|
69
|
+
mad = float(np.median(np.abs(s - med)))
|
|
70
|
+
else:
|
|
71
|
+
med_lo = float(np.median(lo))
|
|
72
|
+
mad = float(np.median(np.abs(lo - med_lo)))
|
|
73
|
+
return 1.4826 * mad
|
|
74
|
+
|
|
75
|
+
def _compute_blackpoint_sigma(img: np.ndarray, sigma: float) -> float:
|
|
76
|
+
"""
|
|
77
|
+
Compute blackpoint using robust sigma so the slider actually works.
|
|
78
|
+
Returns bp clamped to [min..0.99].
|
|
79
|
+
"""
|
|
80
|
+
img = np.asarray(img, dtype=np.float32)
|
|
81
|
+
med = float(np.median(img))
|
|
82
|
+
sig = float(sigma)
|
|
83
|
+
|
|
84
|
+
noise = _robust_sigma_lower_half_fast(img)
|
|
85
|
+
bp = med - sig * noise
|
|
86
|
+
|
|
87
|
+
# Clamp to valid range
|
|
88
|
+
mn = float(img.min())
|
|
89
|
+
bp = max(mn, bp)
|
|
90
|
+
bp = min(bp, 0.99)
|
|
91
|
+
return float(bp), med
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _compute_blackpoint_sigma_per_channel(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
95
|
+
"""
|
|
96
|
+
Per-channel version for unlinked color.
|
|
97
|
+
"""
|
|
98
|
+
sig = float(sigma)
|
|
99
|
+
bp = np.zeros(3, dtype=np.float32)
|
|
100
|
+
for c in range(3):
|
|
101
|
+
ch = img[..., c].astype(np.float32, copy=False)
|
|
102
|
+
med = float(np.median(ch))
|
|
103
|
+
noise = _robust_sigma_lower_half_fast(ch)
|
|
104
|
+
b = med - sig * noise
|
|
105
|
+
b = max(float(ch.min()), b)
|
|
106
|
+
b = min(b, 0.99)
|
|
107
|
+
bp[c] = b
|
|
108
|
+
return bp
|
|
109
|
+
|
|
110
|
+
def hdr_compress_highlights(x: np.ndarray, amount: float, knee: float = 0.75) -> np.ndarray:
|
|
111
|
+
"""
|
|
112
|
+
Smooth soft-knee highlight compression with C1 continuity at the knee.
|
|
113
|
+
|
|
114
|
+
IMPORTANT:
|
|
115
|
+
- We want highlights to get *dimmer* as amount increases.
|
|
116
|
+
- For the Hermite curve on t in [0..1], keeping m0=1 and making m1>1
|
|
117
|
+
puts the curve BELOW f(t)=t (compression), while still ending at 1.
|
|
118
|
+
|
|
119
|
+
amount: 0..1 (0=off)
|
|
120
|
+
knee: 0..1 where compression starts
|
|
121
|
+
"""
|
|
122
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
123
|
+
if a <= 0.0:
|
|
124
|
+
return x.astype(np.float32, copy=False)
|
|
125
|
+
|
|
126
|
+
k = float(np.clip(knee, 0.0, 0.99))
|
|
127
|
+
y = x.astype(np.float32, copy=False)
|
|
128
|
+
|
|
129
|
+
hi = y > k
|
|
130
|
+
if not np.any(hi):
|
|
131
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
132
|
+
|
|
133
|
+
# Normalize region above knee to t in [0..1]
|
|
134
|
+
t = (y[hi] - k) / (1.0 - k)
|
|
135
|
+
t = np.clip(t, 0.0, 1.0)
|
|
136
|
+
|
|
137
|
+
# End slope at t=1:
|
|
138
|
+
# a=0 -> m1=1 (identity)
|
|
139
|
+
# a=1 -> m1=5 (stronger compression but still stable; avoid too-large slopes)
|
|
140
|
+
m1 = 1.0 + 4.0 * a
|
|
141
|
+
m1 = float(np.clip(m1, 1.0, 5.0))
|
|
142
|
+
|
|
143
|
+
# Cubic Hermite: p0=0, p1=1, m0=1 (match slope at knee), m1=m1 (>1 compresses)
|
|
144
|
+
t2 = t * t
|
|
145
|
+
t3 = t2 * t
|
|
146
|
+
|
|
147
|
+
h10 = (t3 - 2.0 * t2 + t) # m0
|
|
148
|
+
h01 = (-2.0 * t3 + 3.0 * t2) # p1
|
|
149
|
+
h11 = (t3 - t2) # m1
|
|
150
|
+
|
|
151
|
+
f = h10 * 1.0 + h01 * 1.0 + h11 * m1
|
|
152
|
+
|
|
153
|
+
y2 = y.copy()
|
|
154
|
+
y2[hi] = k + (1.0 - k) * np.clip(f, 0.0, 1.0)
|
|
155
|
+
|
|
156
|
+
return np.clip(y2, 0.0, 1.0).astype(np.float32, copy=False)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hdr_compress_highlights_L(L: np.ndarray, amount: float, knee: float = 0.75) -> np.ndarray:
|
|
160
|
+
"""
|
|
161
|
+
Same as hdr_compress_highlights(), but for luminance arrays.
|
|
162
|
+
"""
|
|
163
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
164
|
+
if a <= 0.0:
|
|
165
|
+
return L.astype(np.float32, copy=False)
|
|
166
|
+
|
|
167
|
+
k = float(np.clip(knee, 0.0, 0.99))
|
|
168
|
+
y = L.astype(np.float32, copy=False)
|
|
169
|
+
|
|
170
|
+
hi = y > k
|
|
171
|
+
if not np.any(hi):
|
|
172
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
173
|
+
|
|
174
|
+
t = (y[hi] - k) / (1.0 - k)
|
|
175
|
+
t = np.clip(t, 0.0, 1.0)
|
|
176
|
+
|
|
177
|
+
m1 = 1.0 + 4.0 * a
|
|
178
|
+
m1 = float(np.clip(m1, 1.0, 5.0))
|
|
179
|
+
|
|
180
|
+
t2 = t * t
|
|
181
|
+
t3 = t2 * t
|
|
182
|
+
|
|
183
|
+
h10 = (t3 - 2.0 * t2 + t)
|
|
184
|
+
h01 = (-2.0 * t3 + 3.0 * t2)
|
|
185
|
+
h11 = (t3 - t2)
|
|
186
|
+
|
|
187
|
+
f = h10 * 1.0 + h01 * 1.0 + h11 * m1
|
|
188
|
+
|
|
189
|
+
y2 = y.copy()
|
|
190
|
+
y2[hi] = k + (1.0 - k) * np.clip(f, 0.0, 1.0)
|
|
191
|
+
|
|
192
|
+
return np.clip(y2, 0.0, 1.0).astype(np.float32, copy=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolve_rgb_weights_for_luma(method: str, w) -> np.ndarray:
|
|
196
|
+
"""
|
|
197
|
+
Returns normalized RGB weights for recombine_luminance_linear_scale.
|
|
198
|
+
method: rec709/rec601/rec2020 or anything else -> defaults to rec709.
|
|
199
|
+
w: optional weights from resolve_luma_profile_weights
|
|
200
|
+
"""
|
|
201
|
+
if w is not None and np.asarray(w).size == 3:
|
|
202
|
+
rw = np.asarray(w, dtype=np.float32).copy()
|
|
203
|
+
s = float(rw.sum())
|
|
204
|
+
if s > 0:
|
|
205
|
+
rw /= s
|
|
206
|
+
else:
|
|
207
|
+
rw = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
208
|
+
return rw
|
|
209
|
+
|
|
210
|
+
m = str(method).lower()
|
|
211
|
+
if m == "rec601":
|
|
212
|
+
return np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
|
|
213
|
+
if m == "rec2020":
|
|
214
|
+
return np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
|
|
215
|
+
return np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def hdr_compress_color_luminance(
|
|
219
|
+
rgb: np.ndarray,
|
|
220
|
+
amount: float,
|
|
221
|
+
knee: float,
|
|
222
|
+
luma_mode: str = "rec709",
|
|
223
|
+
) -> np.ndarray:
|
|
224
|
+
"""
|
|
225
|
+
WaveScaleHDR-style: compress highlights in luminance, then recombine by linear scaling.
|
|
226
|
+
rgb: (H,W,3) float32 in [0..1] (or close).
|
|
227
|
+
"""
|
|
228
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
229
|
+
if a <= 0.0:
|
|
230
|
+
return rgb.astype(np.float32, copy=False)
|
|
231
|
+
|
|
232
|
+
resolved_method, w, _ = resolve_luma_profile_weights(luma_mode)
|
|
233
|
+
rw = _resolve_rgb_weights_for_luma(resolved_method, w)
|
|
234
|
+
|
|
235
|
+
# Compute luminance from CURRENT rgb, compress luminance, recombine by scale
|
|
236
|
+
if resolved_method == "snr":
|
|
237
|
+
ns = _estimate_noise_sigma_per_channel(rgb)
|
|
238
|
+
Y = compute_luminance(rgb, method="snr", weights=None, noise_sigma=ns)
|
|
239
|
+
else:
|
|
240
|
+
Y = compute_luminance(rgb, method=resolved_method, weights=rw)
|
|
241
|
+
Yc = hdr_compress_highlights(Y, a, knee=float(knee))
|
|
242
|
+
|
|
243
|
+
return recombine_luminance_linear_scale(
|
|
244
|
+
rgb,
|
|
245
|
+
Yc,
|
|
246
|
+
weights=rw,
|
|
247
|
+
blend=1.0,
|
|
248
|
+
highlight_soft_knee=0.25,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _apply_mtf(data: np.ndarray, m: float) -> np.ndarray:
|
|
252
|
+
"""
|
|
253
|
+
Midtones Transfer Function (PixInsight-style).
|
|
254
|
+
Moves current median toward target without hard clipping.
|
|
255
|
+
"""
|
|
256
|
+
m = float(m)
|
|
257
|
+
x = data.astype(np.float32, copy=False)
|
|
258
|
+
term1 = (m - 1.0) * x
|
|
259
|
+
term2 = (2.0 * m - 1.0) * x - m
|
|
260
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
261
|
+
y = term1 / term2
|
|
262
|
+
return np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0).astype(np.float32, copy=False)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _compute_mtf_m_from_median(current_bg: float, target_bg: float) -> float:
|
|
266
|
+
"""
|
|
267
|
+
Solve for 'm' such that MTF moves current median to target median.
|
|
268
|
+
"""
|
|
269
|
+
cb = float(current_bg)
|
|
270
|
+
tb = float(target_bg)
|
|
271
|
+
cb = float(np.clip(cb, 1e-6, 1.0 - 1e-6))
|
|
272
|
+
tb = float(np.clip(tb, 1e-6, 1.0 - 1e-6))
|
|
273
|
+
|
|
274
|
+
den = cb * (2.0 * tb - 1.0) - tb
|
|
275
|
+
if abs(den) < 1e-12:
|
|
276
|
+
den = 1e-12
|
|
277
|
+
m = (cb * (tb - 1.0)) / den
|
|
278
|
+
return float(np.clip(m, 1e-6, 1.0 - 1e-6))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _high_range_rescale_and_softclip(
|
|
282
|
+
img: np.ndarray,
|
|
283
|
+
target_bg: float,
|
|
284
|
+
pedestal: float = 0.001,
|
|
285
|
+
soft_ceil_pct: float = 99.0,
|
|
286
|
+
hard_ceil_pct: float = 99.99,
|
|
287
|
+
floor_sigma: float = 2.7,
|
|
288
|
+
softclip_threshold: float = 0.98,
|
|
289
|
+
softclip_rolloff: float = 2.0,
|
|
290
|
+
) -> np.ndarray:
|
|
291
|
+
"""
|
|
292
|
+
VeraLux-like "ready-to-use" high range manager:
|
|
293
|
+
- robust floor (median - k*sigma)
|
|
294
|
+
- soft/hard ceilings (percentiles)
|
|
295
|
+
- rescale with safety to avoid clipping
|
|
296
|
+
- MTF median -> target_bg
|
|
297
|
+
- soft clip rolloff near 1.0
|
|
298
|
+
|
|
299
|
+
Expects HWC float32-ish, can be out of [0..1] (we fix it safely).
|
|
300
|
+
"""
|
|
301
|
+
x = img.astype(np.float32, copy=False)
|
|
302
|
+
|
|
303
|
+
# Compute luminance proxy for stats (works for mono too)
|
|
304
|
+
if x.ndim == 2 or (x.ndim == 3 and x.shape[2] == 1):
|
|
305
|
+
L = x.squeeze()
|
|
306
|
+
is_rgb = False
|
|
307
|
+
else:
|
|
308
|
+
is_rgb = True
|
|
309
|
+
# Rec709 luma proxy; we only use it for stats
|
|
310
|
+
L = 0.2126 * x[..., 0] + 0.7152 * x[..., 1] + 0.0722 * x[..., 2]
|
|
311
|
+
|
|
312
|
+
# Robust floor (use your existing robust sigma estimator)
|
|
313
|
+
med = float(np.median(L))
|
|
314
|
+
sig = float(_robust_sigma_lower_half_fast(L))
|
|
315
|
+
global_floor = max(float(np.min(L)), med - float(floor_sigma) * sig)
|
|
316
|
+
|
|
317
|
+
# Percentile ceilings (stride sample for speed)
|
|
318
|
+
flat = L.reshape(-1)
|
|
319
|
+
stride = max(1, flat.size // 500000)
|
|
320
|
+
sample = flat[::stride]
|
|
321
|
+
|
|
322
|
+
soft_ceil = float(np.percentile(sample, float(soft_ceil_pct)))
|
|
323
|
+
hard_ceil = float(np.percentile(sample, float(hard_ceil_pct)))
|
|
324
|
+
|
|
325
|
+
if soft_ceil <= global_floor:
|
|
326
|
+
soft_ceil = global_floor + 1e-6
|
|
327
|
+
if hard_ceil <= soft_ceil:
|
|
328
|
+
hard_ceil = soft_ceil + 1e-6
|
|
329
|
+
|
|
330
|
+
ped = float(np.clip(pedestal, 0.0, 0.05))
|
|
331
|
+
|
|
332
|
+
# Contrast scale aims for 0.98, safety scale aims for 1.0
|
|
333
|
+
scale_contrast = (0.98 - ped) / (soft_ceil - global_floor + 1e-12)
|
|
334
|
+
scale_safety = (1.0 - ped) / (hard_ceil - global_floor + 1e-12)
|
|
335
|
+
s = float(min(scale_contrast, scale_safety))
|
|
336
|
+
|
|
337
|
+
y = (x - global_floor) * s + ped
|
|
338
|
+
|
|
339
|
+
# Clamp to [0..1] before MTF + softclip
|
|
340
|
+
y = np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
341
|
+
|
|
342
|
+
# Recompute bg and apply MTF to land median near target
|
|
343
|
+
if target_bg is not None:
|
|
344
|
+
tb = float(target_bg)
|
|
345
|
+
if 0.0 < tb < 1.0:
|
|
346
|
+
if not is_rgb:
|
|
347
|
+
cur = float(np.median(y.squeeze()))
|
|
348
|
+
else:
|
|
349
|
+
Ly = 0.2126 * y[..., 0] + 0.7152 * y[..., 1] + 0.0722 * y[..., 2]
|
|
350
|
+
cur = float(np.median(Ly))
|
|
351
|
+
|
|
352
|
+
if 0.0 < cur < 1.0 and abs(cur - tb) > 1e-3:
|
|
353
|
+
m = _compute_mtf_m_from_median(cur, tb)
|
|
354
|
+
y = _apply_mtf(y, m)
|
|
355
|
+
y = np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
356
|
+
|
|
357
|
+
# Final soft clip rolloff near highlights
|
|
358
|
+
if softclip_threshold is not None and softclip_rolloff is not None:
|
|
359
|
+
y = hdr_compress_highlights(y, amount=1.0, knee=float(softclip_threshold))
|
|
360
|
+
# NOTE: hdr_compress_highlights() already does a hermite rolloff;
|
|
361
|
+
# we map rolloff to that by using knee as threshold.
|
|
362
|
+
|
|
363
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
364
|
+
|
|
365
|
+
|
|
42
366
|
# ---- Optional curves boost (gentle S-curve) ----
|
|
43
367
|
from functools import lru_cache
|
|
44
368
|
|
|
@@ -103,39 +427,67 @@ def apply_curves_adjustment(image: np.ndarray,
|
|
|
103
427
|
|
|
104
428
|
return np.clip(out, 0.0, 1.0)
|
|
105
429
|
|
|
106
|
-
|
|
107
|
-
|
|
108
430
|
# ---- Public API used by Pro ----
|
|
109
431
|
def stretch_mono_image(image: np.ndarray,
|
|
110
432
|
target_median: float,
|
|
111
433
|
normalize: bool = False,
|
|
112
434
|
apply_curves: bool = False,
|
|
113
|
-
curves_boost: float = 0.0
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
435
|
+
curves_boost: float = 0.0,
|
|
436
|
+
blackpoint_sigma: float = 5.0,
|
|
437
|
+
no_black_clip: bool = False,
|
|
438
|
+
hdr_compress: bool = False,
|
|
439
|
+
hdr_amount: float = 0.0,
|
|
440
|
+
hdr_knee: float = 0.75,
|
|
441
|
+
high_range: bool = False,
|
|
442
|
+
highrange_pedestal: float = 0.001,
|
|
443
|
+
highrange_soft_ceil_pct: float = 99.0,
|
|
444
|
+
highrange_hard_ceil_pct: float = 99.99,
|
|
445
|
+
highrange_softclip_threshold: float = 0.98,
|
|
446
|
+
highrange_softclip_rolloff: float = 2.0) -> np.ndarray:
|
|
117
447
|
img = image.astype(np.float32, copy=False)
|
|
118
448
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
449
|
+
sig = float(blackpoint_sigma)
|
|
450
|
+
|
|
451
|
+
if no_black_clip:
|
|
452
|
+
bp = float(img.min())
|
|
453
|
+
med_img = float(np.median(img)) # only if you still need it
|
|
454
|
+
else:
|
|
455
|
+
bp, med_img = _compute_blackpoint_sigma(img, sig)
|
|
126
456
|
|
|
127
|
-
|
|
128
|
-
med_rescaled =
|
|
457
|
+
denom = max(1.0 - bp, 1e-12)
|
|
458
|
+
med_rescaled = (med_img - bp) / denom
|
|
129
459
|
|
|
130
|
-
|
|
460
|
+
# NO rescaled array needed anymore
|
|
461
|
+
out = numba_mono_from_img(img, bp, denom, float(med_rescaled), float(target_median))
|
|
131
462
|
|
|
132
463
|
if apply_curves:
|
|
133
464
|
out = apply_curves_adjustment(out, float(target_median), float(curves_boost))
|
|
465
|
+
|
|
466
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
467
|
+
out = hdr_compress_highlights(out, float(hdr_amount), knee=float(hdr_knee))
|
|
468
|
+
|
|
134
469
|
if normalize:
|
|
135
470
|
mx = float(out.max())
|
|
136
471
|
if mx > 0:
|
|
137
472
|
out = out / mx
|
|
138
473
|
|
|
474
|
+
if high_range:
|
|
475
|
+
out = _high_range_rescale_and_softclip(
|
|
476
|
+
out,
|
|
477
|
+
target_bg=float(target_median),
|
|
478
|
+
pedestal=float(highrange_pedestal),
|
|
479
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
480
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
481
|
+
floor_sigma=float(blackpoint_sigma),
|
|
482
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
483
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
484
|
+
)
|
|
485
|
+
# After high-range manager, normalize is redundant; but keep behavior if user asked.
|
|
486
|
+
if normalize:
|
|
487
|
+
mx = float(out.max())
|
|
488
|
+
if mx > 0:
|
|
489
|
+
out = out / mx
|
|
490
|
+
|
|
139
491
|
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
140
492
|
|
|
141
493
|
|
|
@@ -144,61 +496,229 @@ def stretch_color_image(image: np.ndarray,
|
|
|
144
496
|
linked: bool = True,
|
|
145
497
|
normalize: bool = False,
|
|
146
498
|
apply_curves: bool = False,
|
|
147
|
-
curves_boost: float = 0.0
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
499
|
+
curves_boost: float = 0.0,
|
|
500
|
+
blackpoint_sigma: float = 5.0,
|
|
501
|
+
no_black_clip: bool = False,
|
|
502
|
+
hdr_compress: bool = False,
|
|
503
|
+
hdr_amount: float = 0.0,
|
|
504
|
+
hdr_knee: float = 0.75,
|
|
505
|
+
luma_only: bool = False,
|
|
506
|
+
luma_mode: str = "rec709",
|
|
507
|
+
luma_blend: float = 1.0,
|
|
508
|
+
high_range: bool = False,
|
|
509
|
+
highrange_pedestal: float = 0.001,
|
|
510
|
+
highrange_soft_ceil_pct: float = 99.0,
|
|
511
|
+
highrange_hard_ceil_pct: float = 99.99,
|
|
512
|
+
highrange_softclip_threshold: float = 0.98,
|
|
513
|
+
highrange_softclip_rolloff: float = 2.0) -> np.ndarray:
|
|
151
514
|
img = image.astype(np.float32, copy=False)
|
|
152
515
|
|
|
153
|
-
# Mono/single-channel
|
|
516
|
+
# Mono/single-channel
|
|
154
517
|
if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
|
|
155
518
|
mono = img.squeeze()
|
|
156
|
-
mono_out = stretch_mono_image(
|
|
157
|
-
|
|
519
|
+
mono_out = stretch_mono_image(
|
|
520
|
+
mono,
|
|
521
|
+
target_median,
|
|
522
|
+
normalize=normalize,
|
|
523
|
+
apply_curves=apply_curves,
|
|
524
|
+
curves_boost=curves_boost,
|
|
525
|
+
blackpoint_sigma=blackpoint_sigma,
|
|
526
|
+
hdr_compress=hdr_compress,
|
|
527
|
+
hdr_amount=hdr_amount,
|
|
528
|
+
hdr_knee=hdr_knee,
|
|
529
|
+
high_range=high_range,
|
|
530
|
+
highrange_pedestal=highrange_pedestal,
|
|
531
|
+
highrange_soft_ceil_pct=highrange_soft_ceil_pct,
|
|
532
|
+
highrange_hard_ceil_pct=highrange_hard_ceil_pct,
|
|
533
|
+
highrange_softclip_threshold=highrange_softclip_threshold,
|
|
534
|
+
highrange_softclip_rolloff=highrange_softclip_rolloff,
|
|
535
|
+
)
|
|
158
536
|
return np.stack([mono_out] * 3, axis=-1)
|
|
159
537
|
|
|
160
|
-
|
|
538
|
+
sig = float(blackpoint_sigma)
|
|
539
|
+
|
|
540
|
+
# ----- LUMA ONLY PATH (now with optional blending) -----
|
|
541
|
+
if luma_only:
|
|
542
|
+
b = float(np.clip(luma_blend, 0.0, 1.0))
|
|
543
|
+
|
|
544
|
+
# --- A) Normal linked RGB stretch (same settings, but NOT luma-only) ---
|
|
545
|
+
# Force linked=True here (matches "normal linked stretch" expectation)
|
|
546
|
+
# We compute this first so b=0 is fast-ish if you later optimize.
|
|
547
|
+
if no_black_clip:
|
|
548
|
+
bp = float(img.min())
|
|
549
|
+
med_img = float(np.median(img))
|
|
550
|
+
else:
|
|
551
|
+
bp, med_img = _compute_blackpoint_sigma(img, sig)
|
|
552
|
+
|
|
553
|
+
denom = max(1.0 - bp, 1e-12)
|
|
554
|
+
med_rescaled = (med_img - bp) / denom
|
|
555
|
+
|
|
556
|
+
linked_out = numba_color_linked_from_img(img, bp, denom, float(med_rescaled), float(target_median))
|
|
557
|
+
|
|
558
|
+
if apply_curves:
|
|
559
|
+
linked_out = apply_curves_adjustment(linked_out, float(target_median), float(curves_boost))
|
|
560
|
+
|
|
561
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
562
|
+
linked_out = hdr_compress_color_luminance(
|
|
563
|
+
linked_out,
|
|
564
|
+
amount=float(hdr_amount),
|
|
565
|
+
knee=float(hdr_knee),
|
|
566
|
+
luma_mode="rec709",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if high_range:
|
|
570
|
+
linked_out = _high_range_rescale_and_softclip(
|
|
571
|
+
linked_out,
|
|
572
|
+
target_bg=float(target_median),
|
|
573
|
+
pedestal=float(highrange_pedestal),
|
|
574
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
575
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
576
|
+
floor_sigma=float(blackpoint_sigma),
|
|
577
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
578
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if normalize:
|
|
582
|
+
mx = float(linked_out.max())
|
|
583
|
+
if mx > 0:
|
|
584
|
+
linked_out = linked_out / mx
|
|
585
|
+
|
|
586
|
+
linked_out = np.clip(linked_out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
587
|
+
|
|
588
|
+
# Short-circuit if blend is 0 (pure linked)
|
|
589
|
+
if b <= 0.0:
|
|
590
|
+
return linked_out
|
|
591
|
+
|
|
592
|
+
# --- B) Your existing luma-only recombine stretch ---
|
|
593
|
+
resolved_method, w, _profile_name = resolve_luma_profile_weights(luma_mode)
|
|
594
|
+
|
|
595
|
+
ns = None
|
|
596
|
+
if resolved_method == "snr":
|
|
597
|
+
ns = _estimate_noise_sigma_per_channel(img)
|
|
598
|
+
L = compute_luminance(img, method=resolved_method, weights=w, noise_sigma=ns)
|
|
599
|
+
|
|
600
|
+
Ls = stretch_mono_image(
|
|
601
|
+
L,
|
|
602
|
+
target_median,
|
|
603
|
+
normalize=False,
|
|
604
|
+
apply_curves=apply_curves,
|
|
605
|
+
curves_boost=curves_boost,
|
|
606
|
+
blackpoint_sigma=sig,
|
|
607
|
+
no_black_clip=no_black_clip,
|
|
608
|
+
hdr_compress=False,
|
|
609
|
+
hdr_amount=0.0,
|
|
610
|
+
hdr_knee=hdr_knee,
|
|
611
|
+
high_range=False,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
615
|
+
Ls = hdr_compress_highlights(Ls, float(hdr_amount), knee=float(hdr_knee))
|
|
616
|
+
|
|
617
|
+
if w is not None and np.asarray(w).size == 3:
|
|
618
|
+
rw = np.asarray(w, dtype=np.float32)
|
|
619
|
+
s = float(rw.sum())
|
|
620
|
+
if s > 0:
|
|
621
|
+
rw = rw / s
|
|
622
|
+
else:
|
|
623
|
+
if resolved_method == "rec601":
|
|
624
|
+
rw = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
|
|
625
|
+
elif resolved_method == "rec2020":
|
|
626
|
+
rw = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
|
|
627
|
+
else:
|
|
628
|
+
rw = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
629
|
+
|
|
630
|
+
luma_out = recombine_luminance_linear_scale(
|
|
631
|
+
img,
|
|
632
|
+
Ls,
|
|
633
|
+
weights=rw,
|
|
634
|
+
blend=1.0,
|
|
635
|
+
highlight_soft_knee=0.0,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if high_range:
|
|
639
|
+
luma_out = _high_range_rescale_and_softclip(
|
|
640
|
+
luma_out,
|
|
641
|
+
target_bg=float(target_median),
|
|
642
|
+
pedestal=float(highrange_pedestal),
|
|
643
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
644
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
645
|
+
floor_sigma=float(blackpoint_sigma),
|
|
646
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
647
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if normalize:
|
|
651
|
+
mx = float(luma_out.max())
|
|
652
|
+
if mx > 0:
|
|
653
|
+
luma_out = luma_out / mx
|
|
654
|
+
|
|
655
|
+
luma_out = np.clip(luma_out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
656
|
+
|
|
657
|
+
# --- Final blend: exactly “blend two separate stretched images” ---
|
|
658
|
+
out = (1.0 - b) * linked_out + b * luma_out
|
|
659
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
660
|
+
|
|
661
|
+
# ----- NORMAL RGB PATH -----
|
|
161
662
|
if linked:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
out =
|
|
663
|
+
if no_black_clip:
|
|
664
|
+
bp = float(img.min())
|
|
665
|
+
med_img = float(np.median(img))
|
|
666
|
+
else:
|
|
667
|
+
bp, med_img = _compute_blackpoint_sigma(img, sig)
|
|
668
|
+
|
|
669
|
+
denom = max(1.0 - bp, 1e-12)
|
|
670
|
+
med_rescaled = (med_img - bp) / denom
|
|
671
|
+
|
|
672
|
+
out = numba_color_linked_from_img(img, bp, denom, float(med_rescaled), float(target_median))
|
|
172
673
|
else:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# Compute rescaled medians
|
|
188
|
-
meds = np.median(rescaled, axis=(0, 1)).astype(np.float32)
|
|
189
|
-
|
|
190
|
-
out = numba_color_final_formula_unlinked(rescaled, meds, float(target_median))
|
|
674
|
+
if no_black_clip:
|
|
675
|
+
bp3 = np.array([float(img[...,0].min()),
|
|
676
|
+
float(img[...,1].min()),
|
|
677
|
+
float(img[...,2].min())], dtype=np.float32)
|
|
678
|
+
med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
|
|
679
|
+
else:
|
|
680
|
+
bp3 = _compute_blackpoint_sigma_per_channel(img, sig).astype(np.float32, copy=False)
|
|
681
|
+
med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
|
|
682
|
+
|
|
683
|
+
denom3 = np.maximum(1.0 - bp3, 1e-12).astype(np.float32)
|
|
684
|
+
meds_rescaled3 = (med_img3 - bp3) / denom3
|
|
685
|
+
|
|
686
|
+
out = numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, float(target_median))
|
|
687
|
+
|
|
191
688
|
|
|
192
689
|
if apply_curves:
|
|
193
690
|
out = apply_curves_adjustment(out, float(target_median), float(curves_boost))
|
|
691
|
+
|
|
692
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
693
|
+
# Compress highlights on luminance, then recombine via linear scaling (prevents star bloat)
|
|
694
|
+
out = hdr_compress_color_luminance(
|
|
695
|
+
out,
|
|
696
|
+
amount=float(hdr_amount),
|
|
697
|
+
knee=float(hdr_knee),
|
|
698
|
+
luma_mode="rec709",
|
|
699
|
+
)
|
|
700
|
+
|
|
194
701
|
if normalize:
|
|
195
702
|
mx = float(out.max())
|
|
196
703
|
if mx > 0:
|
|
197
704
|
out = out / mx
|
|
198
705
|
|
|
706
|
+
if high_range:
|
|
707
|
+
out = _high_range_rescale_and_softclip(
|
|
708
|
+
out,
|
|
709
|
+
target_bg=float(target_median),
|
|
710
|
+
pedestal=float(highrange_pedestal),
|
|
711
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
712
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
713
|
+
floor_sigma=float(blackpoint_sigma),
|
|
714
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
715
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
716
|
+
)
|
|
717
|
+
|
|
199
718
|
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
200
719
|
|
|
201
720
|
|
|
721
|
+
|
|
202
722
|
def siril_style_autostretch(image, sigma=3.0):
|
|
203
723
|
"""
|
|
204
724
|
Perform a 'Siril-style histogram stretch' using MAD for robust contrast enhancement.
|