22
33The functions in this module are intended to be used after the physical image
44simulation 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
1818import numpy as np
1919from scipy .ndimage import zoom
2020from 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
2525def 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
365445def _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
421534def _arcsinh_scale_for_band (arcsinh_scale , band ):
0 commit comments