Skip to content

Commit a915205

Browse files
committed
Adding new method for RGB merging.
1 parent d4c1557 commit a915205

2 files changed

Lines changed: 274 additions & 33 deletions

File tree

slsim/ImageSimulation/euclid_image_simulation.py

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,24 @@
22
33
The functions in this module are intended to be used after the physical image
44
simulation has already been performed with ``slsim.ImageSimulation.simulate_image``.
5-
They convert Euclid VIS/Y/J image arrays into RGB products following the image
6-
processing options illustrated in the Euclid Q1 Strong Lensing Discovery Engine
7-
paper.
5+
They convert Euclid VIS/Y/J(/H) image arrays into RGB products following the
6+
image processing options illustrated in the Euclid Q1 Strong Lensing Discovery
7+
Engine paper.
88
See https://arxiv.org/pdf/2503.15324
99
for specific details on the Euclid Q1 image processing steps.
1010
We offer a variety of options for procedures not mentioned in the paper,
1111
or for methods used in the paper that are not applicable to the simulated data.
1212
13-
The expected input order is ``[VIS, Y, J]``. VIS is treated as the high-resolution
14-
luminance channel, while Y and J provide colour information and are resampled to
15-
the VIS image shape when necessary.
13+
The expected input order is ``[VIS, Y, J]`` or ``[VIS, Y, J, H]``. VIS is
14+
treated as the high-resolution luminance channel, while the NISP bands provide
15+
colour information and are resampled to the VIS image shape when necessary.
1616
"""
1717

1818
import numpy as np
1919
from scipy.ndimage import zoom
2020
from lenstronomy.SimulationAPI.ObservationConfig.Euclid import Euclid
2121

22-
EUCLID_Q1_ARCSINH_SCALE = {"VIS": 500.0, "Y": 1.0, "J": 0.5}
22+
EUCLID_Q1_ARCSINH_SCALE = {"VIS": 500.0, "Y": 1.0, "J": 0.5, "H": 0.25}
2323

2424

2525
def euclid_rgb_from_image_list(
@@ -34,6 +34,8 @@ def euclid_rgb_from_image_list(
3434
mtf_region_size=100,
3535
use_luminance=True,
3636
luminance_method="mean",
37+
vis_red_fraction=0.35,
38+
vis_green_fraction=0.45,
3739
channel_gains=None,
3840
saturation=1.0,
3941
):
@@ -45,21 +47,28 @@ def euclid_rgb_from_image_list(
4547
- ``"VIS_Y"``: red = Y, green = median(Y, VIS), blue = VIS.
4648
- ``"VIS_J"``: red = J, green = median(J, VIS), blue = VIS.
4749
- ``"VIS_Y_J"``: red = J, green = Y, blue = VIS.
50+
- ``"VIS_WEIGHTED_Y_J"``: red = weighted sum of J and VIS,
51+
green = weighted sum of Y and VIS, blue = VIS.
52+
- ``"VIS_WEIGHTED_Y_J_H"``: red = weighted sum of H and VIS,
53+
green = weighted sum of the Y/J mean and VIS, blue = VIS.
4854
4955
When ``use_luminance`` is True, the final image luminance is set by the
5056
stretched VIS channel. This preserves the higher VIS spatial resolution while
5157
retaining colour information from the NISP bands.
5258
See https://arxiv.org/pdf/2503.15324
5359
for specific details on the Euclid Q1 image processing steps.
5460
55-
:param image_list: images in order ``[VIS, Y, J]``. Y/J may have lower native
56-
resolution than VIS and are resampled to the VIS shape when required.
61+
:param image_list: images in order ``[VIS, Y, J]`` or ``[VIS, Y, J, H]``.
62+
NISP images may have lower native resolution than VIS and are resampled
63+
to the VIS shape when required.
5764
:type image_list: list[numpy.ndarray]
5865
:param colour: colour mapping to use. Supported values are ``"VIS"``,
59-
``"VIS_Y"``, ``"VIS_J"``, and ``"VIS_Y_J"``.
66+
``"VIS_Y"``, ``"VIS_J"``, ``"VIS_Y_J"``, and
67+
``"VIS_WEIGHTED_Y_J"``, and ``"VIS_WEIGHTED_Y_J_H"``.
6068
:type colour: str
61-
:param stretch: display stretch to apply. Supported values are ``"arcsinh"``
62-
and ``"mtf"``.
69+
:param stretch: display stretch to apply. Supported values are ``"linear"``,
70+
``"arcsinh"``, and ``"mtf"``. ``"linear"`` applies only percentile
71+
normalisation without a nonlinear display transform.
6372
:type stretch: str
6473
:param black_percentile: percentile of each input image mapped to black before
6574
stretching.
@@ -69,7 +78,8 @@ def euclid_rgb_from_image_list(
6978
:type white_percentile: float
7079
:param arcsinh_scale: contrast parameter for the arcsinh stretch. A single
7180
float applies the same value to all bands. If None or ``"euclid_q1"``,
72-
use the Euclid Q1 values ``{"VIS": 500, "Y": 1, "J": 0.5}``. A
81+
use the Euclid Q1 values ``{"VIS": 500, "Y": 1, "J": 0.5}`` and the
82+
display fallback value ``{"H": 0.25}`` for the optional H band. A
7383
dictionary can override individual band values.
7484
:type arcsinh_scale: float or dict or str or None
7585
:param mtf_midtone: midtone transfer function parameter. Values below 0.5
@@ -92,6 +102,17 @@ def euclid_rgb_from_image_list(
92102
channel average and gives a softer display. ``"rec709"`` uses standard
93103
RGB luminance weights.
94104
:type luminance_method: str
105+
:param vis_red_fraction: VIS fraction mixed into the red channel when using
106+
a ``"VIS_WEIGHTED_*"`` colour mode. For ``"VIS_WEIGHTED_Y_J"``, the red
107+
channel is ``(1 - vis_red_fraction) * J + vis_red_fraction * VIS``.
108+
For ``"VIS_WEIGHTED_Y_J_H"``, J is replaced by H.
109+
:type vis_red_fraction: float
110+
:param vis_green_fraction: VIS fraction mixed into the green channel when
111+
using a ``"VIS_WEIGHTED_*"`` colour mode. For ``"VIS_WEIGHTED_Y_J"``,
112+
the green channel is
113+
``(1 - vis_green_fraction) * Y + vis_green_fraction * VIS``. For
114+
``"VIS_WEIGHTED_Y_J_H"``, Y is replaced by the mean of Y and J.
115+
:type vis_green_fraction: float
95116
:param channel_gains: optional display-only multiplicative gains applied to
96117
the final ``(R, G, B)`` channels. ``None`` keeps the Euclid Q1 colour
97118
mapping unchanged. This can be useful for reducing VIS/blue dominance
@@ -112,23 +133,29 @@ def euclid_rgb_from_image_list(
112133
vis = np.asarray(image_list[0], dtype=float)
113134
y = np.asarray(image_list[1], dtype=float) if len(image_list) > 1 else None
114135
j = np.asarray(image_list[2], dtype=float) if len(image_list) > 2 else None
136+
h = np.asarray(image_list[3], dtype=float) if len(image_list) > 3 else None
115137

116138
target_shape = vis.shape
117139
if y is not None and y.shape != target_shape:
118140
y = _resample_to_shape(y, target_shape)
119141
if j is not None and j.shape != target_shape:
120142
j = _resample_to_shape(j, target_shape)
143+
if h is not None and h.shape != target_shape:
144+
h = _resample_to_shape(h, target_shape)
121145

122146
vis = _prepare_channel(vis, black_percentile, white_percentile)
123147
if y is not None:
124148
y = _prepare_channel(y, black_percentile, white_percentile)
125149
if j is not None:
126150
j = _prepare_channel(j, black_percentile, white_percentile)
151+
if h is not None:
152+
h = _prepare_channel(h, black_percentile, white_percentile)
127153

128-
vis_stretched, y_stretched, j_stretched = _stretch_euclid_channels(
154+
vis_stretched, y_stretched, j_stretched, h_stretched = _stretch_euclid_channels(
129155
vis=vis,
130156
y=y,
131157
j=j,
158+
h=h,
132159
stretch=stretch,
133160
arcsinh_scale=arcsinh_scale,
134161
mtf_midtone=mtf_midtone,
@@ -191,8 +218,46 @@ def euclid_rgb_from_image_list(
191218
]
192219
)
193220

221+
elif colour == "VIS_WEIGHTED_Y_J":
222+
_require_channel(y, "Y", colour)
223+
_require_channel(j, "J", colour)
224+
red = _vis_weighted_channel(
225+
vis_channel=vis_stretched,
226+
colour_channel=j_stretched,
227+
vis_fraction=vis_red_fraction,
228+
channel_name="red",
229+
)
230+
green = _vis_weighted_channel(
231+
vis_channel=vis_stretched,
232+
colour_channel=y_stretched,
233+
vis_fraction=vis_green_fraction,
234+
channel_name="green",
235+
)
236+
rgb = np.dstack([red, green, vis_stretched])
237+
238+
elif colour == "VIS_WEIGHTED_Y_J_H":
239+
_require_channel(y, "Y", colour)
240+
_require_channel(j, "J", colour)
241+
_require_channel(h, "H", colour)
242+
red = _vis_weighted_channel(
243+
vis_channel=vis_stretched,
244+
colour_channel=h_stretched,
245+
vis_fraction=vis_red_fraction,
246+
channel_name="red",
247+
)
248+
green = _vis_weighted_channel(
249+
vis_channel=vis_stretched,
250+
colour_channel=0.5 * (y_stretched + j_stretched),
251+
vis_fraction=vis_green_fraction,
252+
channel_name="green",
253+
)
254+
rgb = np.dstack([red, green, vis_stretched])
255+
194256
else:
195-
raise ValueError("colour must be 'VIS', 'VIS_Y', 'VIS_J', or 'VIS_Y_J'.")
257+
raise ValueError(
258+
"colour must be 'VIS', 'VIS_Y', 'VIS_J', 'VIS_Y_J', "
259+
"'VIS_WEIGHTED_Y_J', or 'VIS_WEIGHTED_Y_J_H'."
260+
)
196261

197262
if use_luminance and colour != "VIS":
198263
rgb = _apply_luminance(rgb, luminance, method=luminance_method)
@@ -297,26 +362,31 @@ def _stretch_euclid_channels(
297362
vis,
298363
y,
299364
j,
365+
h,
300366
stretch,
301367
arcsinh_scale,
302368
mtf_midtone,
303369
mtf_target_mean,
304370
mtf_region_size,
305371
):
306-
"""Stretch VIS, Y, and J channels using Euclid Q1 display settings.
372+
"""Stretch VIS, Y, J, and optional H channels using display settings.
307373
308-
The arcsinh branch supports band-dependent Q values. By default these are
309-
the Euclid Q1 values: 500 for VIS, 1 for Y, and 0.5 for J. The MTF branch
310-
supports the Euclid Q1 automatic midtone selection based on the central VIS
311-
image region.
374+
The linear branch returns the percentile-normalised channels without an
375+
additional nonlinear transform. The arcsinh branch supports band-dependent Q
376+
values. By default these are the Euclid Q1 values: 500 for VIS, 1 for Y, and
377+
0.5 for J. The MTF branch supports the Euclid Q1 automatic midtone selection
378+
based on the central VIS image region.
312379
313380
:param vis: normalised VIS channel.
314381
:type vis: numpy.ndarray
315382
:param y: normalised Y channel, or None if not provided.
316383
:type y: numpy.ndarray or None
317384
:param j: normalised J channel, or None if not provided.
318385
:type j: numpy.ndarray or None
319-
:param stretch: stretch name, either ``"arcsinh"`` or ``"mtf"``.
386+
:param h: normalised H channel, or None if not provided.
387+
:type h: numpy.ndarray or None
388+
:param stretch: stretch name, either ``"linear"``, ``"arcsinh"``, or
389+
``"mtf"``.
320390
:type stretch: str
321391
:param arcsinh_scale: arcsinh scale setting, passed to
322392
:func:`_arcsinh_scale_for_band`.
@@ -327,10 +397,14 @@ def _stretch_euclid_channels(
327397
:type mtf_target_mean: float
328398
:param mtf_region_size: central-region size in pixels for automatic MTF.
329399
:type mtf_region_size: int
330-
:return: stretched ``(VIS, Y, J)`` channels. Missing channels are returned as
331-
None.
332-
:rtype: tuple[numpy.ndarray, numpy.ndarray or None, numpy.ndarray or None]
400+
:return: stretched ``(VIS, Y, J, H)`` channels. Missing channels are returned
401+
as None.
402+
:rtype: tuple[numpy.ndarray, numpy.ndarray or None, numpy.ndarray or None,
403+
numpy.ndarray or None]
333404
"""
405+
if stretch == "linear":
406+
return vis, y, j, h
407+
334408
if stretch == "arcsinh":
335409
return (
336410
_arcsinh_stretch(vis, _arcsinh_scale_for_band(arcsinh_scale, "VIS")),
@@ -344,6 +418,11 @@ def _stretch_euclid_channels(
344418
if j is None
345419
else _arcsinh_stretch(j, _arcsinh_scale_for_band(arcsinh_scale, "J"))
346420
),
421+
(
422+
None
423+
if h is None
424+
else _arcsinh_stretch(h, _arcsinh_scale_for_band(arcsinh_scale, "H"))
425+
),
347426
)
348427

349428
if stretch == "mtf":
@@ -357,9 +436,10 @@ def _stretch_euclid_channels(
357436
_midtone_transfer_function(vis, midtone),
358437
None if y is None else _midtone_transfer_function(y, midtone),
359438
None if j is None else _midtone_transfer_function(j, midtone),
439+
None if h is None else _midtone_transfer_function(h, midtone),
360440
)
361441

362-
raise ValueError("stretch must be 'arcsinh' or 'mtf'.")
442+
raise ValueError("stretch must be 'linear', 'arcsinh', or 'mtf'.")
363443

364444

365445
def _mixed_channel(
@@ -402,6 +482,9 @@ def _mixed_channel(
402482
"""
403483
mixed = np.median(np.dstack([colour_channel, vis]), axis=2)
404484

485+
if stretch == "linear":
486+
return mixed
487+
405488
if stretch == "arcsinh":
406489
mixed_scale = _arcsinh_scale_for_mixed_channel(arcsinh_scale, band)
407490
return _arcsinh_stretch(mixed, mixed_scale)
@@ -415,7 +498,37 @@ def _mixed_channel(
415498
)
416499
return _midtone_transfer_function(mixed, midtone)
417500

418-
raise ValueError("stretch must be 'arcsinh' or 'mtf'.")
501+
raise ValueError("stretch must be 'linear', 'arcsinh', or 'mtf'.")
502+
503+
504+
def _vis_weighted_channel(
505+
vis_channel,
506+
colour_channel,
507+
vis_fraction,
508+
channel_name,
509+
):
510+
"""Mix VIS morphology into a NISP colour channel.
511+
512+
This helper supports the simulation-friendly ``"VIS_WEIGHTED_Y_J"`` colour
513+
mode. It keeps the high-resolution VIS structure in the red and green
514+
channels while still retaining the NISP colour information.
515+
516+
:param vis_channel: stretched or normalised VIS image.
517+
:type vis_channel: numpy.ndarray
518+
:param colour_channel: stretched or normalised Y/J image.
519+
:type colour_channel: numpy.ndarray
520+
:param vis_fraction: fraction of VIS to mix into the output channel.
521+
:type vis_fraction: float
522+
:param channel_name: display channel name used in error messages.
523+
:type channel_name: str
524+
:return: weighted channel
525+
``(1 - vis_fraction) * colour_channel + vis_fraction * VIS``.
526+
:rtype: numpy.ndarray
527+
"""
528+
if not 0 <= vis_fraction <= 1:
529+
raise ValueError(f"vis_{channel_name}_fraction must be between 0 and 1.")
530+
531+
return (1 - vis_fraction) * colour_channel + vis_fraction * vis_channel
419532

420533

421534
def _arcsinh_scale_for_band(arcsinh_scale, band):

0 commit comments

Comments
 (0)