Coverage for tests/test_fuzz.py: 100%
77 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 02:06 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 02:06 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11from __future__ import annotations
13import os
14import tempfile
15import unittest
17import astropy.units as u
18import numpy as np
19from click.testing import CliRunner
21from lsst.images import Image, MaskedImage, MaskPlane, MaskSchema
22from lsst.images.cli import main
23from lsst.images.cli._fuzz import shuffle_blocks
24from lsst.images.serialization import read
27class ShuffleBlocksTestCase(unittest.TestCase):
28 """Unit tests for the pure pixel-shuffling helper."""
30 def test_consistency_even_blocks(self) -> None:
31 # Each pixel carries the same code in all three planes, so a shared
32 # per-block permutation must keep the planes aligned afterwards.
33 ny, nx = 4, 4
34 codes = np.arange(ny * nx, dtype=np.float64).reshape(ny, nx)
35 image = codes.copy()
36 variance = codes.copy()
37 mask = codes.astype(np.uint8).reshape(ny, nx, 1).copy()
39 shuffle_blocks(image, mask, variance, (2, 2), np.random.default_rng(7))
41 np.testing.assert_array_equal(image, variance)
42 np.testing.assert_array_equal(image.astype(np.uint8), mask[..., 0])
43 # Every 2x2 block keeps its original multiset of values.
44 for y0 in (0, 2):
45 for x0 in (0, 2):
46 np.testing.assert_array_equal(
47 np.sort(image[y0 : y0 + 2, x0 : x0 + 2], axis=None),
48 np.sort(codes[y0 : y0 + 2, x0 : x0 + 2], axis=None),
49 )
51 def test_partial_edge_blocks(self) -> None:
52 # A 2x2 block does not divide a 5x5 image evenly; the edge blocks are
53 # smaller and must still shuffle without error or value loss.
54 ny, nx = 5, 5
55 codes = np.arange(ny * nx, dtype=np.float64).reshape(ny, nx)
56 image = codes.copy()
57 variance = codes.copy()
58 mask = codes.astype(np.uint8).reshape(ny, nx, 1).copy()
60 shuffle_blocks(image, mask, variance, (2, 2), np.random.default_rng(3))
62 np.testing.assert_array_equal(image, variance)
63 np.testing.assert_array_equal(image.astype(np.uint8), mask[..., 0])
64 np.testing.assert_array_equal(np.sort(image, axis=None), np.sort(codes, axis=None))
67def _make_masked_image() -> MaskedImage:
68 """Build a small MaskedImage with noisy pixels and some mask bits set."""
69 rng = np.random.default_rng(500)
70 mi = MaskedImage(
71 Image(rng.normal(100.0, 8.0, size=(64, 64)), dtype=np.float32, unit=u.nJy),
72 mask_schema=MaskSchema([MaskPlane("BAD", "Bad pixel.")]),
73 metadata={"hello": "world"},
74 )
75 mi.mask.array |= np.multiply.outer(mi.image.array < 100.0, mi.mask.schema.bitmask("BAD"))
76 mi.variance.array = rng.normal(64.0, 0.5, size=mi.bbox.shape).astype(np.float32)
77 return mi
80class FuzzMaskedImageCommandTestCase(unittest.TestCase):
81 """Tests for the fuzz-masked-image CLI command."""
83 def setUp(self) -> None:
84 tmp = tempfile.TemporaryDirectory()
85 self.addCleanup(tmp.cleanup)
86 self.tmp = tmp.name
88 def test_help(self) -> None:
89 result = CliRunner().invoke(main, ["fuzz-masked-image", "--help"])
90 self.assertEqual(result.exit_code, 0, result.output)
92 def test_no_files_errors(self) -> None:
93 result = CliRunner().invoke(main, ["fuzz-masked-image"])
94 self.assertNotEqual(result.exit_code, 0)
96 def test_round_trip_fits(self) -> None:
97 src = os.path.join(self.tmp, "mi.fits")
98 mi = _make_masked_image()
99 mi.write(src)
100 original_image = mi.image.array.copy()
101 original_mask = mi.mask.array.copy()
103 result = CliRunner().invoke(main, ["fuzz-masked-image", src])
104 self.assertEqual(result.exit_code, 0, result.output)
106 out = os.path.join(self.tmp, "mi.fuzzed.fits")
107 self.assertTrue(os.path.exists(out))
108 check = read(out)
109 finite = np.isfinite(original_image)
110 changed = float(np.mean(check.image.array[finite] != original_image[finite]))
111 self.assertGreaterEqual(changed, 0.5)
112 self.assertFalse(np.array_equal(check.mask.array, original_mask))
113 # An untouched part of the object survives the round trip.
114 self.assertEqual(check.metadata.get("hello"), "world")
116 def test_skips_existing_without_overwrite(self) -> None:
117 src = os.path.join(self.tmp, "mi.fits")
118 _make_masked_image().write(src)
119 out = os.path.join(self.tmp, "mi.fuzzed.fits")
120 with open(out, "w") as stream:
121 stream.write("EXISTING")
122 result = CliRunner().invoke(main, ["fuzz-masked-image", src])
123 self.assertEqual(result.exit_code, 0, result.output)
124 with open(out) as stream:
125 self.assertEqual(stream.read(), "EXISTING")
128if __name__ == "__main__":
129 unittest.main()