Coverage for tests / test_pretty_picture_maker.py: 18%
342 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:38 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 08:38 +0000
1import pytest
2import numpy as np
3from lsst.pipe.tasks.prettyPictureMaker._functors._bounds_remapper import BoundsRemapper
4from lsst.pipe.tasks.prettyPictureMaker._functors._lum_scale import LumCompressor
5from lsst.pipe.tasks.prettyPictureMaker._functors._local_contrast import (
6 DiffusionFunction,
7 LocalContrastEnhancer,
8)
9from lsst.pipe.tasks.prettyPictureMaker._functors._color_scale import ColorScaler
10from lsst.pipe.tasks.prettyPictureMaker._functors._exposure_fusion import ExposureBracketer
11from lsst.pipe.tasks.prettyPictureMaker._functors._gamut_fixer import GamutFixer
12from lsst.pipe.tasks.prettyPictureMaker._utils import FeatheredMosaicCreator
13from lsst.pipe.tasks.prettyPictureMaker._colorMapper import lsstRGB
14from lsst.geom import Box2I
17class TestColorScaler:
18 def test_basic_saturation(self):
19 """Verify saturation scaling preserves hue while adjusting chroma."""
20 scaler = ColorScaler(saturation=0.6)
21 lum_old = np.ones((50, 50)) * 0.5
22 lum_new = np.ones((50, 50)) * 0.7
23 np.random.seed(42)
24 a = np.random.uniform(-0.3, 0.3, (50, 50))
25 b = np.random.uniform(-0.3, 0.3, (50, 50))
27 new_a, new_b = scaler(lum_old, lum_new, a, b)
29 original_hue = np.arctan2(b, a)
30 new_hue = np.arctan2(new_b, new_a)
31 np.testing.assert_allclose(original_hue, new_hue, rtol=1e-5)
33 original_chroma = np.sqrt(a**2 + b**2)
34 new_chroma = np.sqrt(new_a**2 + new_b**2)
35 assert np.all(new_chroma < original_chroma)
37 def test_saturation_factor(self):
38 """Verify different saturation values produce different chromaticity."""
39 scaler_low_sat = ColorScaler(saturation=0.3)
40 scaler_high_sat = ColorScaler(saturation=0.9)
42 lum_old = np.ones((50, 50)) * 0.5
43 lum_new = np.ones((50, 50)) * 0.7
44 np.random.seed(42)
45 a = np.random.uniform(-0.3, 0.3, (50, 50))
46 b = np.random.uniform(-0.3, 0.3, (50, 50))
48 new_a_low, _ = scaler_low_sat(lum_old, lum_new, a, b)
49 new_a_high, _ = scaler_high_sat(lum_old, lum_new, a, b)
51 assert not np.allclose(new_a_low, new_a_high)
53 def test_max_chroma_clipping(self):
54 """Verify chromaticity is clipped to maxChroma."""
55 scaler = ColorScaler(saturation=1.0, maxChroma=0.1)
57 lum_old = np.ones((50, 50)) * 0.5
58 lum_new = np.ones((50, 50)) * 0.7
59 np.random.seed(42)
60 a = np.random.uniform(-0.4, 0.4, (50, 50))
61 b = np.random.uniform(-0.4, 0.4, (50, 50))
63 new_a, new_b = scaler(lum_old, lum_new, a, b)
64 new_chroma = np.sqrt(new_a**2 + new_b**2)
66 assert np.all(new_chroma <= 0.1 + 1e-6)
68 def test_zero_chroma(self):
69 """Verify achromatic input remains achromatic."""
70 scaler = ColorScaler(saturation=0.6)
72 lum_old = np.ones((50, 50)) * 0.5
73 lum_new = np.ones((50, 50)) * 0.7
74 a = np.zeros((50, 50))
75 b = np.zeros((50, 50))
77 new_a, new_b = scaler(lum_old, lum_new, a, b)
79 np.testing.assert_array_equal(new_a, 0.0)
80 np.testing.assert_array_equal(new_b, 0.0)
82 def test_equalizer_levels(self):
83 """Verify equalizer_levels modifies chromaticity."""
84 scaler_no_eq = ColorScaler(saturation=0.6, equalizer_levels=None)
85 scaler_with_eq = ColorScaler(saturation=0.6, equalizer_levels=[1.1, 0.9])
87 lum_old = np.ones((50, 50)) * 0.5
88 lum_new = np.ones((50, 50)) * 0.7
89 np.random.seed(42)
90 a = np.random.uniform(-0.3, 0.3, (50, 50))
91 b = np.random.uniform(-0.3, 0.3, (50, 50))
93 new_a_no_eq, new_b_no_eq = scaler_no_eq(lum_old, lum_new, a, b)
94 new_a_with_eq, new_b_with_eq = scaler_with_eq(lum_old, lum_new, a, b)
96 assert not np.allclose((new_a_no_eq, new_b_no_eq), (new_a_with_eq, new_b_with_eq))
99class TestBoundsRemapper:
100 def test_absmax_scaling(self):
101 """Verify scaling using the fixed absMax value."""
102 remapper = BoundsRemapper(absMax=100.0, quant=1.0)
103 img = np.ones((10, 10, 3)) * 50.0
104 expected = np.ones((10, 10, 3)) * 0.5
105 result = remapper(img)
106 np.testing.assert_allclose(result, expected)
108 def test_quant_scaling(self):
109 """Verify scaling using the quantile-based approach (absMax=None)."""
110 remapper = BoundsRemapper(absMax=None, quant=0.5)
111 data = np.linspace(0, 10, 100).reshape(10, 10)
112 img = np.stack([data, data, data], axis=-1)
113 # 95th percentile of linspace(0, 10, 100) is 9.5
114 # scale = 9.5 * 0.5 = 4.75
115 expected = np.clip(img / 4.75, 0, 1)
116 result = remapper(img)
117 np.testing.assert_allclose(result, expected)
119 def test_clipping(self):
120 """Verify that values exceeding the scale are clipped to 1.0."""
121 remapper = BoundsRemapper(absMax=10.0)
122 img = np.array([[[0.0, 0.0, 0.0], [5.0, 5.0, 5.0], [15.0, 15.0, 15.0]]], dtype=float)
123 expected = np.array([[[0.0, 0.0, 0.0], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0]]], dtype=float)
124 result = remapper(img)
125 np.testing.assert_allclose(result, expected)
127 def test_short_circuit(self):
128 """Verify that if max is already 1, the image is returned as-is."""
129 remapper = BoundsRemapper(absMax=100.0)
130 img = np.ones((5, 5, 3))
131 result = remapper(img)
132 np.testing.assert_array_equal(result, img)
135class TestLumCompressor:
136 def test_basic_stretching(self):
137 """Verify that asinh stretching maps values into [0, 1]."""
138 remapper = LumCompressor(stretch=400.0, max=1.0)
139 # Input values ranging from 0 to 1000
140 img = np.linspace(0, 1000, 100).reshape(10, 10)
141 result = remapper(img)
143 assert np.all(result >= 0.0)
144 assert np.all(result <= 1.0)
145 assert result.shape == img.shape
147 def test_contrast_params(self):
148 """Verify that highlight and shadow parameters affect the output."""
149 # Test shadow parameter: increasing shadow should shift values
150 remapper_low_shadow = LumCompressor(shadow=0.0, highlight=1.0)
151 remapper_high_shadow = LumCompressor(shadow=0.5, highlight=1.0)
153 img = np.linspace(0.1, 0.9, 100).reshape(10, 10)
154 res_low = remapper_low_shadow(img)
155 res_high = remapper_high_shadow(img)
157 # High shadow should result in a different distribution
158 assert not np.allclose(res_low, res_high)
160 def test_midtone_adjustment(self):
161 """Verify that midtone parameter shifts the intensity pivot."""
162 remapper_mid_05 = LumCompressor(midtone=0.5)
163 remapper_mid_08 = LumCompressor(midtone=0.8)
165 img = np.linspace(0.1, 0.9, 100).reshape(10, 10)
166 res_05 = remapper_mid_05(img)
167 res_08 = remapper_mid_08(img)
169 assert not np.allclose(res_05, res_08)
171 def test_clipping(self):
172 """Verify that the final output is strictly clipped to [0, 1]."""
173 # Using extreme parameters to force values out of bounds
174 remapper = LumCompressor(stretch=1000.0, highlight=0.1, shadow=0.9)
175 img = np.linspace(0, 100, 100).reshape(10, 10)
176 result = remapper(img)
178 assert np.all(result >= 0.0)
179 assert np.all(result <= 1.0)
182class TestDiffusionFunction:
183 def test_basic(self):
184 """Verify that diffusion produces a different output from input."""
185 diffuser = DiffusionFunction()
186 img = np.random.rand(50, 50)
187 result = diffuser(img)
188 assert not np.allclose(result, img)
190 def test_high_iterations(self):
191 """Verify that high iterations produce more diffused output."""
192 diffuser_low = DiffusionFunction(iterations=1)
193 diffuser_high = DiffusionFunction(iterations=10)
194 img = np.random.rand(50, 50)
195 res_low = diffuser_low(img)
196 res_high = diffuser_high(img)
197 assert not np.allclose(res_low, res_high)
200class TestLocalContrastEnhancer:
201 def test_basic_enhancement(self):
202 """Verify that local contrast enhancement increases contrast."""
203 enhancer = LocalContrastEnhancer(doDiffusion=False)
204 img = np.ones((50, 50)) * 0.5 + np.random.rand(50, 50) * 0.01
205 result = enhancer(img)
206 assert result.std() > img.std()
208 def test_diffusion_off(self):
209 """Verify that turning off diffusion produces different results."""
210 enhancer_with_diffusion = LocalContrastEnhancer(doDiffusion=True)
211 enhancer_without_diffusion = LocalContrastEnhancer(doDiffusion=False)
212 img = np.random.rand(50, 50)
213 res_with = enhancer_with_diffusion(img)
214 res_without = enhancer_without_diffusion(img)
215 assert not np.allclose(res_with, res_without)
218class TestFeatheredMosaicCreator:
219 def test_make_featherings_basic(self):
220 """Verify featherings are created with correct shapes."""
221 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
222 creator._make_featherings((50, 50))
224 assert creator.featherings is not None
225 assert len(creator.featherings) == 4
227 for feather in creator.featherings:
228 assert feather.shape == (50, 50)
230 def test_make_featherings_symmetry(self):
231 """Verify top/bottom and left/right masks are symmetric."""
232 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
233 creator._make_featherings((50, 50))
235 top, bottom, left, right = creator.featherings
237 # Check symmetry excluding the first/last rows/columns where 1e-17 is placed
238 np.testing.assert_allclose(top[1:30, :], bottom[20:49, :][::-1, :], rtol=1e-5)
239 np.testing.assert_allclose(left[:, 1:30], right[:, 20:49][:, ::-1], rtol=1e-5)
241 def test_make_featherings_values(self):
242 """Verify ramp values are correct."""
243 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
244 creator._make_featherings((50, 50))
246 top, bottom, left, right = creator.featherings
248 assert top[0, 0] < 1e-6
249 assert np.allclose(top[20, 0], 1.0, atol=0.1)
251 assert np.allclose(bottom[30, 0], 1.0, atol=0.1)
252 assert bottom[-1, 0] < 1e-6
254 assert np.allclose(left[0, 20], 1.0, atol=0.1)
255 assert right[0, -1] < 1e-6
257 def test_make_featherings_bin_factor(self):
258 """Verify bin_factor reduces resolution."""
259 creator_no_bin = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
260 creator_bin = FeatheredMosaicCreator(patch_grow=10, bin_factor=2)
262 creator_no_bin._make_featherings((50, 50))
263 creator_bin._make_featherings((50, 50))
265 assert creator_bin.featherings[0].shape == creator_no_bin.featherings[0].shape
267 def test_add_to_image_full_overlap(self):
268 """Verify no feathering when box == newBox."""
269 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
270 image = np.zeros((50, 50, 3))
271 patch = np.ones((50, 50, 3)) * 0.5
273 box = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
274 newBox = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
276 creator.add_to_image(image, patch, newBox, box, reverse=False)
278 np.testing.assert_allclose(image, 0.5, atol=0.1)
280 def test_add_to_image_single_edge(self):
281 """Verify only the differing edge gets feathering."""
282 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
283 image = np.zeros((60, 60, 3))
284 patch = np.ones((50, 50, 3)) * 0.5
286 box = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
287 newBox = Box2I(Box2I.Point(0, 5), Box2I.Extent(50, 55))
289 creator.add_to_image(image, patch, newBox, box, reverse=False)
291 assert image.shape == (60, 60, 3)
293 def test_add_to_image_multi_edge(self):
294 """Verify multiple edges get combined feathering."""
295 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
296 image = np.zeros((70, 70, 3))
297 patch = np.ones((50, 50, 3)) * 0.5
299 box = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
300 newBox = Box2I(Box2I.Point(5, 5), Box2I.Extent(55, 55))
302 creator.add_to_image(image, patch, newBox, box, reverse=False)
304 assert image.shape == (70, 70, 3)
306 def test_add_to_image_rgb(self):
307 """Verify RGB (3D) images are handled correctly."""
308 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
309 image = np.zeros((50, 50, 3))
310 patch = np.ones((50, 50, 3)) * 0.5
312 box = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
313 newBox = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
315 creator.add_to_image(image, patch, newBox, box, reverse=False)
317 assert image.shape == (50, 50, 3)
318 np.testing.assert_allclose(image[0, 0, :], 0.5, atol=0.1)
320 def test_add_to_image_reverse(self):
321 """Verify reverse flips the patch."""
322 creator = FeatheredMosaicCreator(patch_grow=10, bin_factor=1)
323 image = np.zeros((50, 50, 3))
324 patch = np.ones((50, 50, 3))
326 box = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
327 newBox = Box2I(Box2I.Point(0, 0), Box2I.Extent(50, 50))
329 creator.add_to_image(image, patch, newBox, box, reverse=True)
331 assert image.shape == (50, 50, 3)
334class TestExposureBracketer:
335 def test_basic_fusion(self):
336 """Verify exposure bracketing fusion produces a balanced result."""
337 bracketer = ExposureBracketer()
338 img = np.random.rand(50, 50) * 0.5
339 result = bracketer(img)
341 assert result.shape == img.shape
342 assert np.all(result >= 0.0)
343 assert np.all(result <= 1.0)
345 def test_single_bracket(self):
346 """Verify single bracket returns scaled image without fusion."""
347 bracketer = ExposureBracketer(exposureBrackets=[1.25])
348 img = np.random.rand(50, 50) * 0.5
349 result = bracketer(img)
350 expected = img / 1.25
352 np.testing.assert_allclose(result, expected)
354 def test_no_brackets(self):
355 """Verify None brackets returns input unchanged."""
356 bracketer = ExposureBracketer(exposureBrackets=None)
357 img = np.random.rand(50, 50)
358 result = bracketer(img)
360 np.testing.assert_array_equal(result, img)
362 def test_different_brackets(self):
363 """Verify different bracket configurations produce different outputs."""
364 bracketer_default = ExposureBracketer()
365 bracketer_custom = ExposureBracketer(exposureBrackets=[1.5, 1, 0.5])
367 img = np.random.rand(50, 50) * 0.5
368 res_default = bracketer_default(img)
369 res_custom = bracketer_custom(img)
371 assert not np.allclose(res_default, res_custom)
373 def test_clipping_behavior(self):
374 """Verify values > 1.0 receive reduced weighting during fusion."""
375 bracketer = ExposureBracketer()
376 img = np.random.rand(50, 50) * 0.5
377 img[25, 25] = 1.5
379 result = bracketer(img)
381 assert result.shape == img.shape
382 assert np.all(result >= 0.0)
385class TestGamutFixer:
386 def test_gamut_fixer_none_method(self):
387 """Verify 'none' method returns original RGB conversion."""
388 fixer = GamutFixer(gamutMethod="none")
389 Lab = np.random.rand(50, 50, 3) * 0.4 - 0.2
390 Lab[:, :, 0] = Lab[:, :, 0] * 0.8 + 0.1
391 Lab[25, 25, :] = [0.5, 0.5, 0.5]
392 xyz_whitepoint = (0.31272, 0.32903)
394 result = fixer(Lab, xyz_whitepoint)
396 assert result.shape == Lab.shape[:-1] + (3,)
398 def test_gamut_fixer_inpaint_method(self):
399 """Verify 'inpaint' method fixes small out-of-gamut regions."""
400 fixer = GamutFixer(gamutMethod="inpaint", max_size=10000)
401 Lab = np.random.rand(50, 50, 3) * 0.4 - 0.2
402 Lab[:, :, 0] = Lab[:, :, 0] * 0.8 + 0.1
403 xyz_whitepoint = (0.31272, 0.32903)
405 result = fixer(Lab, xyz_whitepoint)
407 assert result.shape == Lab.shape[:-1] + (3,)
408 assert np.all(result <= 1.0)
410 def test_gamut_fixer_mapping_method(self):
411 """Verify 'mapping' method remaps out-of-gamut colors."""
412 fixer = GamutFixer(gamutMethod="mapping")
413 Lab = np.random.rand(50, 50, 3) * 0.4 - 0.2
414 Lab[:, :, 0] = Lab[:, :, 0] * 0.8 + 0.1
415 Lab[25, 25, :] = [0.8, 0.8, 0.8]
416 xyz_whitepoint = (0.31272, 0.32903)
418 result = fixer(Lab, xyz_whitepoint)
420 assert result.shape == Lab.shape[:-1] + (3,)
421 assert np.all(result <= 1.0)
423 def test_gamut_fixer_heal_method(self):
424 """Verify 'heal' method heals out-of-gamut regions."""
425 fixer = GamutFixer(gamutMethod="heal", max_size=1000)
426 Lab = np.random.rand(50, 50, 3) * 0.4 - 0.2
427 Lab[:, :, 0] = Lab[:, :, 0] * 0.8 + 0.1
428 xyz_whitepoint = (0.31272, 0.32903)
430 result = fixer(Lab, xyz_whitepoint)
432 assert result.shape == Lab.shape[:-1] + (3,)
433 assert np.all(result <= 1.0)
435 def test_gamut_fixer_no_out_of_bounds(self):
436 fixer = GamutFixer(gamutMethod="inpaint")
437 Lab = np.random.rand(50, 50, 3) * 0.4 - 0.2
438 Lab[:, :, 0] = Lab[:, :, 0] * 0.8 + 0.1
439 xyz_whitepoint = (0.31272, 0.32903)
441 result = fixer(Lab, xyz_whitepoint)
443 assert result.shape == Lab.shape[:-1] + (3,)
444 assert np.all(result >= 0.0)
445 assert np.all(result <= 1.0)
448class TestColorMapper:
449 def test_basic_integration(self):
450 """Verify lsstRGB produces valid RGB output with defaults."""
451 np.random.seed(42)
452 r = np.random.rand(100, 100) * 0.99 + 0.01
453 g = np.random.rand(100, 100) * 0.99 + 0.01
454 b = np.random.rand(100, 100) * 0.99 + 0.01
456 result = lsstRGB(r, g, b)
458 assert result.shape == (100, 100, 3)
459 assert result.dtype == np.float64
460 assert np.all(result >= 0.0)
461 assert np.all(result <= 1.0)
463 def test_shape_validation(self):
464 """Verify ValueError is raised for mismatched shapes."""
465 np.random.seed(42)
466 r = np.random.rand(100, 100)
467 g = np.random.rand(100, 99)
468 b = np.random.rand(100, 100)
470 with pytest.raises(ValueError):
471 lsstRGB(r, g, b)
473 def test_none_functors(self):
474 """Verify all functors set to None returns remapped RGB."""
475 np.random.seed(42)
476 r = np.random.rand(100, 100) * 0.99 + 0.01
477 g = np.random.rand(100, 100) * 0.99 + 0.01
478 b = np.random.rand(100, 100) * 0.99 + 0.01
480 result = lsstRGB(
481 r,
482 g,
483 b,
484 local_contrast=None,
485 scale_lum=None,
486 scale_color=None,
487 bracketing_function=None,
488 gamut_remapping_function=None,
489 remap_bounds=None,
490 cieWhitePoint=(0.31272, 0.32903),
491 )
493 assert result.shape == (100, 100, 3)
494 np.testing.assert_allclose(result[..., 0], r, rtol=1e-5)
495 np.testing.assert_allclose(result[..., 1], g, rtol=1e-5)
496 np.testing.assert_allclose(result[..., 2], b, rtol=1e-5)
498 def test_psf_deconvolution(self):
499 """Verify PSF deconvolution produces different output."""
500 np.random.seed(42)
501 r = np.random.rand(100, 100) * 0.99 + 0.01
502 g = np.random.rand(100, 100) * 0.99 + 0.01
503 b = np.random.rand(100, 100) * 0.99 + 0.01
505 x = np.linspace(-2, 2, 5)
506 xx, yy = np.meshgrid(x, x)
507 psf = np.exp(-(xx**2 + yy**2))
508 psf /= psf.sum()
510 result_no_psf = lsstRGB(r, g, b, psf=None)
511 result_with_psf = lsstRGB(r, g, b, psf=psf)
513 assert result_no_psf.shape == result_with_psf.shape
514 assert not np.allclose(result_no_psf, result_with_psf)
516 def test_cie_white_point(self):
517 """Verify different white points produce different outputs."""
518 np.random.seed(42)
519 r = np.random.rand(100, 100) * 0.99 + 0.01
520 g = np.random.rand(100, 100) * 0.99 + 0.01
521 b = np.random.rand(100, 100) * 0.99 + 0.01
523 result_default = lsstRGB(r, g, b, cieWhitePoint=(0.28, 0.28))
524 result_d65 = lsstRGB(r, g, b, cieWhitePoint=(0.31272, 0.32903))
526 assert not np.allclose(result_default, result_d65)