Coverage for python/lsst/analysis/ap/imageQA.py: 78%
75 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-24 10:06 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-24 10:06 +0000
1# This file is part of analysis_ap.
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# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Pixel-level QA for AP difference images.
24Two utilities live here:
26- `image_diff_stats` reports basic statistics (median, MAD, stdev, kurtosis,
27 mask-plane fractions) of a single difference image.
28- `pixel_compare` aligns two difference images for the same data id and
29 returns their pixel-wise difference, ratio, mask differences, and a small
30 summary.
32Both are intended for human-driven analysis from a notebook. The first is
33also designed to be mapped over a list of dataIds to produce a per-detector
34quality DataFrame.
35"""
37from __future__ import annotations
39__all__ = ["image_diff_stats", "image_diff_stats_table", "pixel_compare"]
41from dataclasses import dataclass
42from typing import Iterable, Mapping
44import numpy as np
45import pandas as pd
47from .imageUtils import get_visit_image
50def _finite_view(array):
51 """Return only the finite entries of ``array`` as a flat 1-D view."""
52 flat = np.asarray(array).ravel()
53 return flat[np.isfinite(flat)]
56def _basic_stats(array):
57 """Median, MAD, stdev, and kurtosis of the finite entries of ``array``.
59 Kurtosis is the Pearson definition (``E[((x-mu)/sigma)**4] - 3``); zero
60 for a Gaussian. Computed without scipy so this module has no extra deps.
61 """
62 finite = _finite_view(array)
63 if finite.size == 0: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 return {"median": np.nan, "mad": np.nan, "stddev": np.nan, "kurtosis": np.nan}
65 median = float(np.median(finite))
66 mad = float(np.median(np.abs(finite - median)))
67 stddev = float(np.std(finite))
68 if stddev > 0: 68 ↛ 72line 68 didn't jump to line 72 because the condition on line 68 was always true
69 z = (finite - finite.mean()) / stddev
70 kurtosis = float(np.mean(z**4) - 3.0)
71 else:
72 kurtosis = np.nan
73 return {"median": median, "mad": mad, "stddev": stddev, "kurtosis": kurtosis}
76def _mask_plane_fractions(mask):
77 """Return ``{"frac_<plane>": fraction_of_pixels_set}`` for a mask.
79 Parameters
80 ----------
81 mask : `lsst.images.Mask`
82 The mask whose planes are to be summed.
84 Returns
85 -------
86 fractions : `dict` [`str`, `float`]
87 Per-plane fraction of pixels where that plane bit is set.
88 """
89 if mask.bbox.area == 0: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 return {}
91 return {f"frac_{name}": float(np.mean(mask.get(name))) for name in mask.schema.names}
94def _mask_difference(mask1, mask2):
95 """Return where two masks differ in any plane.
97 Planes are matched by name; a plane absent from one mask counts as unset
98 there.
100 Parameters
101 ----------
102 mask1, mask2 : `lsst.images.Mask`
103 Masks with the same shape.
105 Returns
106 -------
107 differ : `numpy.ndarray` [`bool`]
108 True where at least one plane is set in only one mask.
109 """
110 differ = np.zeros(mask1.bbox.shape, dtype=bool)
111 for name in set(mask1.schema.names) | set(mask2.schema.names):
112 set1 = mask1.get(name) if name in mask1.schema.names else False
113 set2 = mask2.get(name) if name in mask2.schema.names else False
114 differ |= set1 != set2
115 return differ
118def image_diff_stats(butler, visit, detector, dataset_name="difference_image"):
119 """Summary statistics on a single difference image.
121 Parameters
122 ----------
123 butler : `lsst.daf.butler.Butler`
124 Butler initialized with the relevant collections.
125 visit, detector : `int`
126 Data id selecting the exposure to load.
127 dataset_name : `str`, optional
128 Butler dataset type name to load. Defaults to ``"difference_image"``.
130 Returns
131 -------
132 stats : `dict`
133 ``{"visit", "detector", "median", "mad", "stddev", "kurtosis"}`` plus
134 a ``frac_<PLANE>`` entry per mask plane on the image, named as in
135 `lsst.images` (e.g. ``frac_SATURATED``).
136 """
137 diff = get_visit_image(butler, dataset_name, {"visit": visit, "detector": detector},
138 image_type="difference")
139 stats = {"visit": visit, "detector": detector}
140 stats.update(_basic_stats(diff.image.array))
141 stats.update(_mask_plane_fractions(diff.mask))
142 return stats
145def image_diff_stats_table(butler,
146 data_ids: Iterable[Mapping[str, int]],
147 dataset_name="difference_image"):
148 """Run `image_diff_stats` over many data ids and return a DataFrame.
150 Parameters
151 ----------
152 butler : `lsst.daf.butler.Butler`
153 data_ids : iterable of mapping
154 Each mapping must contain at least ``visit`` and ``detector`` keys.
155 dataset_name : `str`, optional
156 See `image_diff_stats`.
158 Returns
159 -------
160 table : `pandas.DataFrame`
161 One row per dataId, indexed by ``(visit, detector)``. Mask-plane
162 columns may be missing on some rows if the corresponding plane is not
163 defined on every exposure; pandas fills those with NaN.
164 """
165 rows = []
166 for data_id in data_ids:
167 rows.append(image_diff_stats(butler,
168 data_id["visit"],
169 data_id["detector"],
170 dataset_name=dataset_name))
171 if not rows:
172 return pd.DataFrame()
173 return pd.DataFrame(rows).set_index(["visit", "detector"]).sort_index()
176@dataclass
177class PixelCompareResult:
178 """Container for `pixel_compare` outputs.
180 Attributes
181 ----------
182 img1, img2 : `lsst.images.DifferenceImage`
183 The two loaded images, in case the caller wants their masks/WCS.
184 diff : `numpy.ndarray`
185 Pixel array of ``img1 - img2``.
186 ratio : `numpy.ndarray`
187 Pixel array of ``img1 / img2``; NaN where ``img2 == 0``.
188 mask_diff : `numpy.ndarray` [`bool`]
189 True where at least one mask plane, matched by name, is set in only
190 one of the two images.
191 summary : `dict`
192 ``{"visit", "detector", "diff_median", "diff_mad", "diff_stddev",
193 "n_mask_pixels_changed", "frac_mask_pixels_changed"}``.
194 """
195 img1: object
196 img2: object
197 diff: np.ndarray
198 ratio: np.ndarray
199 mask_diff: np.ndarray
200 summary: dict
203def pixel_compare(butler1, butler2, visit, detector,
204 dataset_name="difference_image"):
205 """Compare two difference images for the same data id, pixel by pixel.
207 Pull the same dataId from two butlers (or two collections of one butler)
208 and inspect the residuals. A legacy image is converted to `lsst.images`
209 form when read, so it can be compared with an `lsst.images` one.
211 Parameters
212 ----------
213 butler1, butler2 : `lsst.daf.butler.Butler`
214 Two butlers, possibly the same instance with different collections.
215 visit, detector : `int`
216 Data id to load from both butlers.
217 dataset_name : `str`, optional
218 Butler dataset type name to load from each butler. Defaults to
219 ``"difference_image"``.
221 Returns
222 -------
223 result : `PixelCompareResult`
225 Raises
226 ------
227 ValueError
228 If the two loaded images differ in shape (cannot be aligned by simple
229 subtraction).
230 """
231 data_id = {"visit": visit, "detector": detector}
232 img1 = get_visit_image(butler1, dataset_name, data_id, image_type="difference")
233 img2 = get_visit_image(butler2, dataset_name, data_id, image_type="difference")
235 a1 = img1.image.array
236 a2 = img2.image.array
237 if a1.shape != a2.shape: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 raise ValueError(f"Image shapes differ for visit={visit} detector={detector}: "
239 f"{a1.shape} vs {a2.shape}")
241 diff = a1 - a2
242 with np.errstate(divide="ignore", invalid="ignore"):
243 ratio = np.where(a2 != 0, a1 / a2, np.nan)
245 mask_diff = _mask_difference(img1.mask, img2.mask)
247 finite = _finite_view(diff)
248 if finite.size: 248 ↛ 253line 248 didn't jump to line 253 because the condition on line 248 was always true
249 diff_median = float(np.median(finite))
250 diff_mad = float(np.median(np.abs(finite - diff_median)))
251 diff_stddev = float(np.std(finite))
252 else:
253 diff_median = diff_mad = diff_stddev = np.nan
255 n_changed = int(np.count_nonzero(mask_diff))
256 summary = {
257 "visit": visit,
258 "detector": detector,
259 "diff_median": diff_median,
260 "diff_mad": diff_mad,
261 "diff_stddev": diff_stddev,
262 "n_mask_pixels_changed": n_changed,
263 "frac_mask_pixels_changed": (float(n_changed / mask_diff.size)
264 if mask_diff.size else 0.0),
265 }
266 return PixelCompareResult(img1=img1, img2=img2, diff=diff, ratio=ratio,
267 mask_diff=mask_diff, summary=summary)