Coverage for tests/test_difference_image_extras.py: 59%
269 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-10 01:39 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-10 01:39 -0800
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.
12from __future__ import annotations
14import dataclasses
15import logging
16import math
17import os
18import uuid
19from pathlib import Path
20from typing import Any
22import astropy.units as u
23import numpy as np
24import pytest
25from astro_metadata_translator import ObservationInfo
27from lsst.daf.butler import DimensionUniverse
28from lsst.images import (
29 Box,
30 DetectorFrame,
31 DifferenceImage,
32 DifferenceImageTemplateInfo,
33 DifferenceImageTemplateInput,
34 Image,
35 MaskPlane,
36 MaskSchema,
37 Polygon,
38)
39from lsst.images.cameras import Detector
40from lsst.images.convolution_kernels import ConvolutionKernel, ImageBasisConvolutionKernel
41from lsst.images.fields import ChebyshevField
42from lsst.images.psfs import GaussianPointSpreadFunction
43from lsst.images.serialization import read_archive
44from lsst.images.tests import (
45 DP2_TEMPLATE_COADD_DATASETS,
46 DP2_VISIT_DETECTOR_DATA_ID,
47 RoundtripFits,
48 assert_values_equal,
49 current_fixture_path,
50 get_dp2_exposure_record,
51 make_random_sky_projection,
52 reset_afw_mask_planes, # noqa: F401
53)
55try:
56 import astshim
57 from lsst.afw.detection import GaussianPsf as LegacyGaussianPsf
58 from lsst.afw.geom import Polygon as LegacyPolygon
59 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform
60 from lsst.afw.geom import makeCdMatrix, makeModifiedWcs, makeSkyWcs
61 from lsst.afw.image import Exposure as LegacyExposure
62 from lsst.afw.image import ImageD as LegacyImageD
63 from lsst.afw.math import Kernel as LegacyKernel
64 from lsst.afw.table import ExposureCatalog as LegacyExposureCatalog
65 from lsst.daf.base import PropertyList as LegacyPropertyList
66 from lsst.geom import Box2D as LegacyBox2D
67 from lsst.geom import Box2I as LegacyBox2I
68 from lsst.geom import Extent2I as LegacyExtent2I
69 from lsst.geom import Point2D as LegacyPoint2D
70 from lsst.geom import Point2I as LegacyPoint2I
71 from lsst.geom import SpherePoint as LegacySpherePoint
72 from lsst.geom import arcseconds, degrees
73 from lsst.meas.algorithms import CoaddPsf as LegacyCoaddPsf
74except ImportError:
75 type LegacyExposure = Any # type: ignore[no-redef]
76 type LegacyImageD = Any # type: ignore[no-redef]
77 type LegacyKernel = Any # type: ignore[no-redef]
78 type LegacyPropertyList = Any # type: ignore[no-redef]
79 type LegacyCoaddPsf = Any # type: ignore[no-redef]
80 type LegacyExposureCatalog = Any # type: ignore[no-redef]
81 type LegacyExtent2I = Any # type: ignore[no-redef]
84EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
87@dataclasses.dataclass
88class _LegacyTestData:
89 kernel: LegacyKernel
90 template_metadata: LegacyPropertyList
91 template_psf: LegacyCoaddPsf
92 exposure: LegacyExposure
93 detector_frame: DetectorFrame
94 exposure_record: Any = None
97@pytest.fixture
98def legacy_test_data(reset_afw_mask_planes: None) -> _LegacyTestData: # noqa: F811
99 """Return a struct of legacy test objects loaded from EXTERNAL_DATA_DIR.
101 Skips if TESTDATA_IMAGES_DIR is unset or afw is unavailable.
102 """
103 # reset_afw_mask_planes will have already skipped if afw is not available.
104 from lsst.afw.image import ExposureFitsReader
106 if EXTERNAL_DATA_DIR is None: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was always true
107 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
108 kernel_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "difference_kernel.fits")
109 template_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "template_detector.fits")
110 exposure_filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "difference_image.fits")
111 kernel = LegacyKernel.readFits(kernel_filename)
112 template_reader = ExposureFitsReader(template_filename)
113 template_metadata = template_reader.readMetadata()
114 template_psf = template_reader.readPsf()
115 exposure = ExposureFitsReader(exposure_filename).read()
116 exposure_record = get_dp2_exposure_record(DimensionUniverse())
117 detector_frame = DetectorFrame(
118 **DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.from_legacy(exposure.getDetector().getBBox())
119 )
120 return _LegacyTestData(
121 kernel=kernel,
122 template_metadata=template_metadata,
123 template_psf=template_psf,
124 exposure=exposure,
125 detector_frame=detector_frame,
126 exposure_record=exposure_record,
127 )
130def compare_kernel_to_legacy(kernel: ConvolutionKernel, legacy_kernel: LegacyKernel) -> None:
131 """Assert that a ConvolutionKernel matches a legacy Kernel at sampled
132 points.
133 """
134 xy_array = kernel.bounds.bbox.meshgrid(3)
135 legacy_im = LegacyImageD(kernel.kernel_bbox.to_legacy())
136 for x, y in zip(xy_array.x.flat, xy_array.y.flat):
137 x = round(x)
138 y = round(y)
139 im = kernel.compute_kernel_image(x=x, y=y)
140 legacy_im.array[...] = 0.0
141 legacy_kernel.computeImage(legacy_im, doNormalize=False, x=x, y=y)
142 assert_values_equal(im.array, legacy_im.array, rtol=1e-15, atol=1e-15)
145def _sanity_check_template_info(
146 template_info: list[DifferenceImageTemplateInfo], detector_frame: DetectorFrame
147) -> None:
148 """Check that a list of DifferenceImageTemplateInfo looks plausible."""
149 assert len(template_info) == 9
150 assert {info.dataset_id for info in template_info} == set(DP2_TEMPLATE_COADD_DATASETS.keys())
151 assert {
152 frozenset({"skymap": info.skymap, "tract": info.tract, "patch": info.patch, "band": "r"}.items())
153 for info in template_info
154 } == {frozenset(v.items()) for v in DP2_TEMPLATE_COADD_DATASETS.values()}
155 assert not any(info.psf_shape_flag for info in template_info)
156 assert not any(math.isnan(info.psf_shape_xx) for info in template_info)
157 assert not any(math.isnan(info.psf_shape_yy) for info in template_info)
158 assert not any(math.isnan(info.psf_shape_xy) for info in template_info)
159 assert all(detector_frame.bbox.contains(info.bounds.bbox) for info in template_info)
160 # Patches overlap, so total area is a bit more than detector area.
161 assert sum(info.bounds.area for info in template_info) < 1.5 * detector_frame.bbox.area
164def _make_difference_image(legacy_test_data: _LegacyTestData) -> DifferenceImage:
165 """Return a DifferenceImage with kernel and template components
166 attached.
167 """
168 difference_image = DifferenceImage.from_legacy(
169 legacy_test_data.exposure, exposure_record=legacy_test_data.exposure_record
170 )
171 difference_image.kernel = ImageBasisConvolutionKernel.from_legacy(legacy_test_data.kernel)
172 difference_image.templates = DifferenceImageTemplateInfo.from_legacy(
173 legacy_test_data.detector_frame,
174 legacy_test_data.template_psf,
175 legacy_test_data.template_metadata,
176 DP2_TEMPLATE_COADD_DATASETS,
177 instrument="LSSTCam",
178 )
179 return difference_image
182def test_roundtrip(legacy_test_data: _LegacyTestData) -> None:
183 """Test round-tripping a DifferenceImage with extra components through
184 FITS.
185 """
186 difference_image = _make_difference_image(legacy_test_data)
187 with RoundtripFits(difference_image, storage_class="DifferenceImage") as roundtrip:
188 pass
189 compare_kernel_to_legacy(roundtrip.result.kernel, legacy_test_data.kernel)
190 _sanity_check_template_info(roundtrip.result.templates, legacy_test_data.detector_frame)
191 assert all(info.instrument == "LSSTCam" for info in roundtrip.result.templates)
192 assert all(info.inputs == [] for info in roundtrip.result.templates)
195def test_roundtrip_template_inputs(legacy_test_data: _LegacyTestData) -> None:
196 """Test that the input images recorded for each template round-trip
197 through FITS.
198 """
199 difference_image = _make_difference_image(legacy_test_data)
200 for n, info in enumerate(difference_image.templates):
201 info.inputs = [
202 DifferenceImageTemplateInput(
203 visit=2025061200123 + n,
204 detector=94,
205 mid_mjd_tai=60838.13125 + n,
206 weight=0.75,
207 bounds=info.bounds,
208 )
209 ]
210 with RoundtripFits(difference_image, storage_class="DifferenceImage") as roundtrip:
211 pass
212 for original, result in zip(difference_image.templates, roundtrip.result.templates, strict=True):
213 assert len(result.inputs) == 1
214 assert result.inputs[0].visit == original.inputs[0].visit
215 assert result.inputs[0].detector == 94
216 assert result.inputs[0].mid_mjd_tai == original.inputs[0].mid_mjd_tai
217 assert result.inputs[0].weight == 0.75
218 np.testing.assert_array_equal(result.inputs[0].bounds.x_vertices, original.bounds.x_vertices)
219 np.testing.assert_array_equal(result.inputs[0].bounds.y_vertices, original.bounds.y_vertices)
222def test_kernel_component_read(legacy_test_data: _LegacyTestData) -> None:
223 """Verify the kernel component of a DifferenceImage can be read on its
224 own.
226 Requires a butler; skips when `lsst.daf.butler` is absent. Butler-free
227 assertions live in `test_roundtrip`.
228 """
229 difference_image = _make_difference_image(legacy_test_data)
230 with RoundtripFits(difference_image, storage_class="DifferenceImage") as roundtrip:
231 compare_kernel_to_legacy(roundtrip.get("kernel"), legacy_test_data.kernel)
234def test_difference_kernel(legacy_test_data: _LegacyTestData) -> None:
235 """Test converting a legacy difference kernel to and from the new type."""
236 kernel = ImageBasisConvolutionKernel.from_legacy(legacy_test_data.kernel)
237 compare_kernel_to_legacy(kernel, legacy_test_data.kernel)
238 legacy_kernel_2 = kernel.to_legacy()
239 compare_kernel_to_legacy(kernel, legacy_kernel_2)
242def test_template_info(legacy_test_data: _LegacyTestData) -> None:
243 """Test extracting template information from legacy template_detector
244 components.
245 """
246 template_info = DifferenceImageTemplateInfo.from_legacy(
247 legacy_test_data.detector_frame,
248 legacy_test_data.template_psf,
249 legacy_test_data.template_metadata,
250 DP2_TEMPLATE_COADD_DATASETS,
251 )
252 _sanity_check_template_info(template_info, legacy_test_data.detector_frame)
253 assert all(info.instrument == "UNKNOWN" for info in template_info)
256def test_template_info_instrument(legacy_test_data: _LegacyTestData) -> None:
257 """Test that the instrument passed to from_legacy is recorded on every
258 template.
259 """
260 template_info = DifferenceImageTemplateInfo.from_legacy(
261 legacy_test_data.detector_frame,
262 legacy_test_data.template_psf,
263 legacy_test_data.template_metadata,
264 DP2_TEMPLATE_COADD_DATASETS,
265 instrument="LSSTCam",
266 )
267 assert template_info
268 assert all(info.instrument == "LSSTCam" for info in template_info)
271def test_template_info_no_overlap_is_skipped(
272 legacy_test_data: _LegacyTestData, caplog: pytest.LogCaptureFixture
273) -> None:
274 """Test that template coadd whose patch does not overlap the detector is
275 skipped in DifferenceImageTemplateInfo.from_legacy, instead of raising
276 NoOverlapError.
277 """
278 psf = legacy_test_data.template_psf
279 n_components = psf.getComponentCount()
280 assert n_components > 1
282 # Rebuild the CoaddPsf from its per-component getters, shifting component
283 # 0's patch bbox far from the detector so the bbox intersection in
284 # from_legacy is empty (NoOverlapError). Keep the tract/patch columns so
285 # from_legacy can still resolve its butler info.
286 schema = LegacyExposureCatalog.Table.makeMinimalSchema()
287 schema.addField("weight", type="D")
288 schema.addField("tract", type="I")
289 schema.addField("patch", type="I")
290 catalog = LegacyExposureCatalog(schema)
291 shift = LegacyExtent2I(50000, 50000)
292 for n in range(n_components):
293 record = catalog.addNew()
294 record.setId(psf.getId(n))
295 record.setWcs(psf.getWcs(n))
296 record.setPsf(psf.getPsf(n))
297 record.setValidPolygon(psf.getValidPolygon(n))
298 bbox = psf.getBBox(n)
299 if n == 0:
300 bbox = bbox.shiftedBy(shift)
301 record.setBBox(bbox)
302 record.set("weight", psf.getWeight(n))
303 record.set("tract", psf.getTract(n))
304 record.set("patch", psf.getPatch(n))
305 shifted_psf = LegacyCoaddPsf(catalog, psf.getCoaddWcs(), psf.getAveragePosition())
307 skipped_tract = psf.getTract(0)
308 skipped_patch = psf.getPatch(0)
309 with caplog.at_level(logging.ERROR):
310 template_info = DifferenceImageTemplateInfo.from_legacy(
311 legacy_test_data.detector_frame,
312 shifted_psf,
313 legacy_test_data.template_metadata,
314 DP2_TEMPLATE_COADD_DATASETS,
315 log=logging.getLogger("test_template_info_no_overlap"),
316 )
317 assert len(template_info) == n_components - 1
318 assert all((info.tract, info.patch) != (skipped_tract, skipped_patch) for info in template_info)
319 assert not any(info.psf_shape_flag for info in template_info)
320 assert all(legacy_test_data.detector_frame.bbox.contains(info.bounds.bbox) for info in template_info)
321 assert any(
322 f"No overlap with tract={skipped_tract}, patch={skipped_patch}" in record.message
323 for record in caplog.records
324 )
327LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
330def test_difference_image_repr_str_pinned() -> None:
331 """Pin the exact str and repr output of a DifferenceImage."""
332 rng = np.random.default_rng(500)
333 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096])
334 mask_schema = MaskSchema([MaskPlane("M1", "D1")])
335 obs_info = ObservationInfo(instrument="LSSTCam", detector_num=4, physical_filter="r1")
336 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector)
337 image = Image(42, shape=(1024, 1024), unit=u.nJy)
338 sky_projection = make_random_sky_projection(rng, det_frame, det_frame.bbox)
339 di = DifferenceImage(
340 image,
341 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]),
342 mask_schema=mask_schema,
343 sky_projection=sky_projection,
344 detector=detector,
345 obs_info=obs_info,
346 band="r",
347 )
348 assert str(di) == "DifferenceImage(Image([y=0:1024, x=0:1024], int64), ['M1'])"
349 assert repr(di) == (
350 "DifferenceImage(Image(..., bbox=Box(y=Interval(start=0, stop=1024), x=Interval(start=0, stop=1024)),"
351 " dtype=dtype('int64')), mask_schema=MaskSchema([MaskPlane(name='M1', description='D1')],"
352 " dtype=dtype('uint8')))"
353 )
356FIXTURE_DIR = Path(__file__).parent / "data" / "schemas"
359def _make_template(
360 tract: int = 1,
361 patch: int = 2,
362 *,
363 skymap: str = "sky",
364 dataset_run: str = "run",
365 psf_shape_xx: float = 4.0,
366 psf_shape_yy: float = 4.0,
367 psf_shape_xy: float = 0.0,
368 psf_shape_flag: bool = False,
369) -> DifferenceImageTemplateInfo:
370 """Return a template info struct with defaults for everything a test does
371 not care about.
372 """
373 return DifferenceImageTemplateInfo(
374 skymap=skymap,
375 tract=tract,
376 patch=patch,
377 dataset_id=uuid.uuid4(),
378 dataset_run=dataset_run,
379 bounds=Polygon(x_vertices=[-0.5, 3.5, -0.5], y_vertices=[-0.5, -0.5, 3.5]),
380 psf_shape_xx=psf_shape_xx,
381 psf_shape_yy=psf_shape_yy,
382 psf_shape_xy=psf_shape_xy,
383 psf_shape_flag=psf_shape_flag,
384 )
387def test_template_info_instrument_defaults_to_unknown() -> None:
388 """A template without a recorded instrument, such as one read from a file
389 written before the field existed, has the instrument ``"UNKNOWN"``.
390 """
391 assert _make_template().instrument == "UNKNOWN"
394@pytest.mark.parametrize("curvature", [-5e-7, 5e-7])
395def test_template_info_bounds_cover_distorted_detector(
396 reset_afw_mask_planes: None, # noqa: F811
397 curvature: float,
398) -> None:
399 """Test that the bounds of two overlapping patches together cover a
400 distorted detector whose edges their shared boundary crosses.
402 The detector edges are distorted in the coadd frame by about 2 pixels, so
403 clipping each patch to the detector's corner-only footprint in the coadd
404 frame would leave a sliver along one edge outside both patches' bounds.
405 """
406 crval = LegacySpherePoint(150.0, 2.0, degrees)
407 coadd_wcs = makeSkyWcs(LegacyPoint2D(1023.5, 2047.5), crval, makeCdMatrix(0.2 * arcseconds))
408 # x' = x + curvature*(y - yc)**2 and y' = y, with its exact inverse.
409 yc = 2047.5
410 forward = np.array(
411 [
412 [1.0, 1, 1, 0],
413 [curvature, 1, 0, 2],
414 [-2 * curvature * yc, 1, 0, 1],
415 [curvature * yc**2, 1, 0, 0],
416 [1.0, 2, 0, 1],
417 ]
418 )
419 inverse = np.array(
420 [
421 [1.0, 1, 1, 0],
422 [-curvature, 1, 0, 2],
423 [2 * curvature * yc, 1, 0, 1],
424 [-curvature * yc**2, 1, 0, 0],
425 [1.0, 2, 0, 1],
426 ]
427 )
428 detector_wcs = makeModifiedWcs(LegacyTransform(astshim.PolyMap(forward, inverse)), coadd_wcs, False)
429 schema = LegacyExposureCatalog.Table.makeMinimalSchema()
430 schema.addField("weight", type="D")
431 schema.addField("tract", type="I")
432 schema.addField("patch", type="I")
433 catalog = LegacyExposureCatalog(schema)
434 patches = {
435 0: LegacyBox2I(LegacyPoint2I(-500, -500), LegacyPoint2I(2600, 2100)),
436 1: LegacyBox2I(LegacyPoint2I(-500, 2000), LegacyPoint2I(2600, 4600)),
437 }
438 for patch, patch_bbox in patches.items():
439 record = catalog.addNew()
440 record.setWcs(coadd_wcs)
441 record.setPsf(LegacyGaussianPsf(21, 21, 2.0))
442 record.setBBox(patch_bbox)
443 record.setValidPolygon(LegacyPolygon(LegacyBox2D(patch_bbox)))
444 record.set("weight", 1.0)
445 record.set("tract", 7)
446 record.set("patch", patch)
447 psf = LegacyCoaddPsf(catalog, detector_wcs, LegacyPoint2D(1023.5, 2047.5))
448 detector_frame = DetectorFrame(instrument="Inst", visit=1, detector=1, bbox=Box.factory[0:4096, 0:2048])
450 template_info = DifferenceImageTemplateInfo.from_legacy_psf(
451 detector_frame, psf, "skymap", {(7, patch): (uuid.uuid4(), "run") for patch in patches}
452 )
454 assert len(template_info) == 2
455 # The centers of the pixels along all four detector edges.
456 ys = np.arange(4096, dtype=float)
457 xs = np.arange(2048, dtype=float)
458 x = np.concatenate([np.zeros_like(ys), np.full_like(ys, 2047.0), xs, xs])
459 y = np.concatenate([ys, ys, np.zeros_like(xs), np.full_like(xs, 4095.0)])
460 covered = np.zeros(x.size, dtype=bool)
461 for info in template_info:
462 assert detector_frame.bbox.contains(info.bounds.bbox)
463 covered |= info.bounds.contains(x=x, y=y)
464 assert covered.all()
467def test_describe_templates_hoists_shared_values() -> None:
468 """A value every template shares becomes a field instead of a column."""
469 templates = [_make_template(1, 2), _make_template(1, 3)]
470 fields, table = DifferenceImageTemplateInfo._describe_templates(templates)
471 assert {field.label: field.value for field in fields} == {"skymap": "sky", "template run": "run"}
472 assert table.title == "Templates"
473 assert table.columns == ["Tract", "Patch", "PSF \N{GREEK SMALL LETTER SIGMA}", "Dataset ID"]
474 assert [row[:3] for row in table.rows] == [[1, 2, "2.000"], [1, 3, "2.000"]]
477def test_describe_templates_keeps_varying_values_as_columns() -> None:
478 """A value that differs between templates stays in the table."""
479 templates = [_make_template(skymap="a", dataset_run="r1"), _make_template(skymap="b", dataset_run="r1")]
480 fields, table = DifferenceImageTemplateInfo._describe_templates(templates)
481 # Only the run is shared, so only the run is hoisted.
482 assert [field.label for field in fields] == ["template run"]
483 assert table.columns[0] == "Skymap"
484 assert [row[0] for row in table.rows] == ["a", "b"]
487def test_describe_templates_marks_unusable_psf_shapes() -> None:
488 """A flagged or degenerate PSF shape reports no radius, and the flag
489 column appears only when some template carries it.
490 """
491 good = _make_template(psf_shape_xx=9.0, psf_shape_yy=9.0)
492 flagged = _make_template(psf_shape_flag=True)
493 degenerate = _make_template(psf_shape_xx=1.0, psf_shape_yy=1.0, psf_shape_xy=1.0)
494 _, table = DifferenceImageTemplateInfo._describe_templates([good, degenerate])
495 assert "PSF flag" not in table.columns
496 assert [row[2] for row in table.rows] == ["3.000", "n/a"]
497 _, table = DifferenceImageTemplateInfo._describe_templates([good, flagged])
498 flag_column = table.columns.index("PSF flag")
499 assert [row[2] for row in table.rows] == ["3.000", "n/a"]
500 assert [row[flag_column] for row in table.rows] == ["", "set"]
503def test_difference_image_describe_reports_templates_and_kernel() -> None:
504 """A deserialized difference image describes both of the parts it adds to
505 a visit image.
506 """
507 difference_image = read_archive(str(current_fixture_path(FIXTURE_DIR, "difference_image", variant="dp2")))
508 report = difference_image.describe()
509 tables = {table.title: table for table in report.tables}
510 assert len(tables["Templates"].rows) == len(difference_image.templates)
511 assert "skymap" in {field.label for field in report.fields}
512 assert report.children["kernel"].type_name == "ImageBasisConvolutionKernel"
513 # Both renderers run over the whole thing without error.
514 assert isinstance(report._repr_html_(), str)
515 report.__rich__()
518def test_difference_image_describe_brief_skips_templates_and_kernel() -> None:
519 """Brief reports feed repr and str, which name neither part."""
520 difference_image = read_archive(str(current_fixture_path(FIXTURE_DIR, "difference_image", variant="dp2")))
521 report = difference_image.describe(brief=True)
522 assert report.tables == []
523 assert "kernel" not in report.children
526def test_convolution_kernel_describes_itself() -> None:
527 """A kernel summarizes its basis rather than listing it."""
528 spatial = [ChebyshevField(Box.factory[0:10, 0:20], np.array([[1.0]])) for _ in range(3)]
529 kernel = ImageBasisConvolutionKernel(np.ones((3, 5, 5)), spatial)
530 report = kernel.describe()
531 values = {field.label: field.value for field in report.fields}
532 assert values["basis images"] == 3
533 assert values["spatial variation"] == "ChebyshevField"
534 assert report.children == {}
535 assert str(kernel) == "ImageBasisConvolutionKernel with 3 basis images over [y=0:10, x=0:20]"