Coverage for python/lsst/images/fields/_chebyshev.py: 59%

227 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +0000

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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("ChebyshevField", "ChebyshevFieldSerializationModel") 

15 

16from collections.abc import Iterator 

17from typing import TYPE_CHECKING, Any, ClassVar, Literal, final 

18 

19import astropy.units 

20import numpy as np 

21import pydantic 

22 

23from .._concrete_bounds import BoundsSerializationModel 

24from .._geom import YX, Bounds, Box 

25from .._image import Image 

26from ..describe import DescribeOptions, FieldRole, Report, ReportField 

27from ..serialization import ArchiveTree, InlineArray, InputArchive, InvalidParameterError, OutputArchive, Unit 

28from ._base import BaseField 

29 

30if TYPE_CHECKING: 

31 try: 

32 from lsst.afw.math import BackgroundMI as LegacyBackground 

33 from lsst.afw.math import Chebyshev1Function2D as LegacyChebyshev1Function2D 

34 from lsst.afw.math import ChebyshevBoundedField as LegacyChebyshevBoundedField 

35 except ImportError: 

36 type LegacyBackground = Any # type: ignore[no-redef] 

37 type LegacyChebyshevBoundedField = Any # type: ignore[no-redef] 

38 type LegacyChebyshev1Function2D = Any # type: ignore[no-redef] 

39 

40 

41@final 

42class ChebyshevField(BaseField): 

43 """A 2-d Chebyshev polynomial over a rectangular region. 

44 

45 Parameters 

46 ---------- 

47 bounds 

48 The region where this field can be evaluated. The ``bbox`` of this 

49 region is grown by half a pixel on all sides and then used to remap 

50 coordinates to ``[-1, 1]x[-1, 1]``, which is the natural domain of a 

51 2-d Chebyshev polynomial. 

52 coefficients 

53 Coefficients for the 2-d Chebyshev polynomial of the first kind, as a 

54 2-d matrix in which element ``[p, q]`` corresponds to the coefficient 

55 of ``T_p(y) T_q(x)``. Will be set to read-only in place. 

56 unit 

57 Units of the field. 

58 """ 

59 

60 def __init__( 

61 self, bounds: Bounds, coefficients: np.ndarray, *, unit: astropy.units.UnitBase | None = None 

62 ) -> None: 

63 self._bounds = bounds 

64 self._coefficients = coefficients 

65 self._coefficients.flags.writeable = False 

66 self._unit = unit 

67 # Compute the scaling and translation that map points in the bbox 

68 # (including an extra 0.5 on all sides, since the bbox is int-based) 

69 # to [-1, 1]. 

70 bbox = bounds.bbox 

71 self._xs = 2.0 / bbox.x.size 

72 self._xt = bbox.x.min + 0.5 * bbox.x.size - 0.5 

73 self._ys = 2.0 / bbox.y.size 

74 self._yt = bbox.y.min + 0.5 * bbox.y.size - 0.5 

75 

76 def __eq__(self, other: object) -> bool: 

77 if type(other) is not ChebyshevField: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 return NotImplemented 

79 return ( 

80 self._bounds == other._bounds 

81 and self._unit == other._unit 

82 and np.array_equal(self._coefficients, other._coefficients, equal_nan=True) 

83 ) 

84 

85 __hash__ = None # type: ignore[assignment] 

86 

87 @staticmethod 

88 def fit( 

89 bounds: Bounds, 

90 data: np.ndarray | astropy.units.Quantity, 

91 order: int | None = None, 

92 *, 

93 y: np.ndarray, 

94 x: np.ndarray, 

95 weight: np.ndarray | None = None, 

96 y_order: int | None = None, 

97 x_order: int | None = None, 

98 triangular: bool = True, 

99 unit: astropy.units.UnitBase | None = None, 

100 ) -> ChebyshevField: 

101 """Fit a Chebyshev field to data points using linear least squares. 

102 

103 Parameters 

104 ---------- 

105 bounds 

106 Bounding box over which the Chebyshev field is defined. 

107 data 

108 Data points to fit. If this is an `astropy.units.Quantity`, 

109 this sets the units of the field and the ``unit`` argument cannot 

110 also be provided. 

111 order 

112 Maximum order for the Chebyshev polynomial in both dimensions. 

113 y 

114 Y coordinates of the data points. Must have either the same 

115 shape as ``data`` (providing the coordinates for all points 

116 directly), or be a 1-d array with the same size as 

117 ``data.shape[0]`` (when ``data`` is a 2-d image and ``y`` provides 

118 the coordinates of the rows). 

119 x 

120 X coordinates of the data points. Must have either the same 

121 shape as ``data`` (providing the coordinates for all points 

122 directly), or be a 1-d array with the same size as 

123 ``data.shape[1]`` (when ``data`` is a 2-d image and ``x`` provides 

124 the coordinates of the columns). 

125 weight 

126 Weights to apply to the data points. Must have the same shape as 

127 ``data``. 

128 y_order 

129 Maximum order for the Chebyshev polynomial in ``y``. Requires 

130 ``x_order`` to also be provided. Incompatible with ``order``. 

131 x_order 

132 Maximum order for the Chebyshev polynomial in ``x``. Requires 

133 ``y_order`` to also be provided. Incompatible with ``order``. 

134 triangular 

135 If `True`, only fit for coefficients of ``T_p(y) T_q(x)`` where 

136 ``p + q <= max(y_order, x_order)``. 

137 unit 

138 Units of the returned field. 

139 """ 

140 match (order, x_order, y_order): 

141 case (int(), None, None): 

142 x_order = order 

143 y_order = order 

144 case (None, int(), int()): 144 ↛ 146line 144 didn't jump to line 146 because the pattern on line 144 always matched

145 pass 

146 case _: 

147 raise TypeError("Either 'order' (only) or both 'x_order' and 'y_order' must be provided.") 

148 if weight is not None and weight.shape != data.shape: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 raise ValueError(f"Shape of 'data' {data.shape} does not match 'weight' {weight.shape}.") 

150 if isinstance(data, astropy.units.Quantity): 

151 if unit is not None: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 raise TypeError("If 'data' is a Quantity, 'unit' cannot be provided separately.") 

153 unit = data.unit 

154 data = data.to_value() 

155 result = ChebyshevField(bounds, np.zeros((y_order + 1, x_order + 1), dtype=np.float64), unit=unit) 

156 if len(data.shape) == 2 and len(x.shape) == 1 and len(y.shape) == 1: 

157 if data.shape != y.shape + x.shape: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 raise ValueError( 

159 f"Shape of 2-d 'data' {data.shape} does not match 1-d 'y' {y.shape} and/or 'x' {x.shape}." 

160 ) 

161 matrix = result._make_grid_matrix(x=x, y=y, triangular=triangular) 

162 else: 

163 if data.shape != y.shape: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 raise ValueError(f"Shape of 'data' {data.shape} does not match 'y' {y.shape}.") 

165 if data.shape != x.shape: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 raise ValueError(f"Shape of 'data' {data.shape} does not match 'x' {x.shape}.") 

167 matrix = result._make_general_matrix(x=x, y=y, triangular=triangular) 

168 if weight is not None: 

169 weight = weight.ravel() # copies only if needed 

170 matrix *= weight[:, np.newaxis] 

171 data = data.flatten() # always copies 

172 data *= weight 

173 mask = np.logical_and(weight > 0, np.isfinite(data)) 

174 else: 

175 data = data.ravel() 

176 mask = np.isfinite(data) 

177 n_good = mask.sum() 

178 if n_good == 0: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true

179 raise ValueError("No good data points.") 

180 if n_good < data.size: 

181 data = data[mask] 

182 matrix = matrix[mask, :] 

183 packed_coefficients, *_ = np.linalg.lstsq(matrix, data) 

184 result._coefficients.flags.writeable = True 

185 for i, pq in result._packing_indices(triangular): 

186 result._coefficients[pq.y, pq.x] = packed_coefficients[i] 

187 result._coefficients.flags.writeable = False 

188 return result 

189 

190 @property 

191 def bounds(self) -> Bounds: 

192 return self._bounds 

193 

194 @property 

195 def unit(self) -> astropy.units.UnitBase | None: 

196 return self._unit 

197 

198 @property 

199 def x_order(self) -> int: 

200 """Maximum polynomial order in the column dimension (`int`).""" 

201 return self._coefficients.shape[1] - 1 

202 

203 @property 

204 def y_order(self) -> int: 

205 """Maximum polynomial order in the row dimension (`int`).""" 

206 return self._coefficients.shape[0] - 1 

207 

208 @property 

209 def order(self) -> int: 

210 """Maximum polynomial order in either dimension (`int`).""" 

211 return max(self.x_order, self.y_order) 

212 

213 @property 

214 def coefficients(self) -> np.ndarray: 

215 """Coefficients for the 2-d Chebyshev polynomial of the first kind, 

216 as a 2-d matrix in which element ``[p, q]`` corresponds to the 

217 coefficient of ``T_p(y) T_q(x)``. 

218 """ 

219 return self._coefficients 

220 

221 @property 

222 def is_constant(self) -> bool: 

223 return self.x_order == 0 and self.y_order == 0 

224 

225 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

226 """Return a `Report` describing this Chebyshev field. 

227 

228 Parameters 

229 ---------- 

230 options : `DescribeOptions`, optional 

231 Unused; accepted for interface compatibility. 

232 """ 

233 return Report( 

234 type_name="ChebyshevField", 

235 summary=f"ChebyshevField order={self.order} over {self.bounds}", 

236 fields=[ 

237 ReportField(label="bounds", value=self.bounds, role=FieldRole.DERIVED), 

238 ReportField(label="unit", value=self.unit, role=FieldRole.DERIVED), 

239 ReportField(label="order", value=self.order, role=FieldRole.DERIVED), 

240 ReportField(label="x_order", value=self.x_order, role=FieldRole.DERIVED), 

241 ReportField(label="y_order", value=self.y_order, role=FieldRole.DERIVED), 

242 ], 

243 ) 

244 

245 def _evaluate( 

246 self, *, x: np.ndarray, y: np.ndarray, quantity: bool 

247 ) -> np.ndarray | astropy.units.Quantity: 

248 y, x = np.broadcast_arrays(y, x) 

249 m = self._remap(x=x.astype(np.float64, copy=True), y=y.astype(np.float64, copy=True)) 

250 # We swap x and y relative to Numpy's docs because that's how our 

251 # coefficients are ordered. 

252 v = np.polynomial.chebyshev.chebval2d(m.y, m.x, self._coefficients) 

253 if quantity: 

254 return astropy.units.Quantity(v, self.unit) 

255 return v 

256 

257 def render(self, bbox: Box | None = None, *, dtype: np.typing.DTypeLike | None = None) -> Image: 

258 if bbox is None: 

259 bbox = self.bounds.bbox 

260 m = self._remap( 

261 x=bbox.x.arange.astype(np.float64), 

262 y=bbox.y.arange.astype(np.float64), 

263 ) 

264 # We swap x and y relative to Numpy's docs because that's how our 

265 # coefficients and images are ordered. 

266 v = np.polynomial.chebyshev.chebgrid2d(m.y, m.x, self._coefficients) 

267 return Image(v, bbox=bbox, unit=self.unit, dtype=dtype) 

268 

269 def _multiply_constant( 

270 self, factor: float | astropy.units.Quantity | astropy.units.UnitBase 

271 ) -> ChebyshevField: 

272 factor, unit = self._handle_factor_units(factor) 

273 return ChebyshevField(self.bounds, self.coefficients * factor, unit=unit) 

274 

275 def serialize(self, archive: OutputArchive[Any]) -> ChebyshevFieldSerializationModel: 

276 """Serialize the Chebyshev field to an output archive. 

277 

278 Parameters 

279 ---------- 

280 archive 

281 Archive to write to. 

282 """ 

283 return ChebyshevFieldSerializationModel( 

284 bounds=self.bounds.serialize(), 

285 coefficients=self.coefficients, 

286 unit=self.unit, 

287 ) 

288 

289 @staticmethod 

290 def _get_archive_tree_type( 

291 pointer_type: type[Any], 

292 ) -> type[ChebyshevFieldSerializationModel]: 

293 """Return the serialization model type for this object for an archive 

294 type that uses the given pointer type. 

295 """ 

296 return ChebyshevFieldSerializationModel 

297 

298 @staticmethod 

299 def from_legacy( 

300 legacy: LegacyChebyshevBoundedField, 

301 unit: astropy.units.UnitBase | None = None, 

302 bounds: Bounds | None = None, 

303 ) -> ChebyshevField: 

304 """Convert from a legacy `lsst.afw.math.ChebyshevBoundedField`. 

305 

306 Parameters 

307 ---------- 

308 legacy 

309 Legacy field to convert. 

310 unit 

311 The units of the returned field (`lsst.afw.math.BoundedField` 

312 objects do not know their units). 

313 bounds 

314 The bounds of the returned field, if they should be different from 

315 the bounding box of ``legacy``. 

316 """ 

317 bbox = Box.from_legacy(legacy.getBBox()) 

318 if bounds is not None: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 if bounds.bbox != bbox: 

320 raise ValueError( 

321 "Custom bounds when converting a ChebyshevBoundedField must not change the bbox." 

322 ) 

323 else: 

324 bounds = bbox 

325 return ChebyshevField(bounds=bounds, coefficients=legacy.getCoefficients(), unit=unit) 

326 

327 def to_legacy(self) -> LegacyChebyshevBoundedField: 

328 """Convert to a legacy `lsst.afw.math.ChebyshevBoundedField`.""" 

329 from lsst.afw.math import ChebyshevBoundedField as LegacyChebyshevBoundedField 

330 

331 return LegacyChebyshevBoundedField(self.bounds.bbox.to_legacy(), self.coefficients) 

332 

333 @staticmethod 

334 def from_legacy_background( 

335 legacy_background: LegacyBackground, 

336 bounds: Bounds | None = None, 

337 unit: astropy.units.UnitBase | None = None, 

338 ) -> ChebyshevField: 

339 """Convert from a legacy `lsst.afw.math.BackgroundMI` instance. 

340 

341 Parameters 

342 ---------- 

343 legacy_background 

344 Legacy background object to convert. 

345 bounds 

346 The bounds of the returned field, if they should be different from 

347 the bounding box of ``legacy_background``. 

348 unit 

349 The units of the returned field (`lsst.afw.math.Background` 

350 objects do not know their units). 

351 """ 

352 from lsst.afw.math import ApproximateControl 

353 

354 approx_control = legacy_background.getBackgroundControl().getApproximateControl() 

355 stats_image = legacy_background.getStatsImage() 

356 if approx_control.getStyle() != ApproximateControl.CHEBYSHEV: 

357 raise TypeError("Legacy background does not use Chebyshev approximation.") 

358 if approx_control.getWeighting(): 

359 weight = stats_image.variance.array ** (-0.5) 

360 else: 

361 weight = None 

362 x = legacy_background.getBinCentersX() 

363 y = legacy_background.getBinCentersY() 

364 bbox = Box.from_legacy(legacy_background.getImageBBox()) 

365 if bounds is not None: 

366 if bounds.bbox != bbox: 

367 raise ValueError( 

368 "Custom bounds when converting a Chebyshev background must not change the bbox." 

369 ) 

370 else: 

371 bounds = bbox 

372 return ChebyshevField.fit( 

373 bounds, 

374 stats_image.image.array, 

375 x=x, 

376 y=y, 

377 x_order=approx_control.getOrderX(), 

378 y_order=approx_control.getOrderY(), 

379 weight=weight, 

380 unit=unit, 

381 ) 

382 

383 @staticmethod 

384 def from_legacy_function2( 

385 legacy_function2: LegacyChebyshev1Function2D, 

386 bounds: Bounds | None = None, 

387 unit: astropy.units.Unit | None = None, 

388 ) -> ChebyshevField: 

389 """Convert from a legacy `lsst.afw.math.Chebyshev1Function2D`. 

390 

391 Parameters 

392 ---------- 

393 legacy_function2 

394 Legacy function object to convert. 

395 bounds 

396 The bounds of the returned field, if they should be different from 

397 the bounding box of ``legacy_background``. 

398 unit 

399 The units of the returned field. 

400 """ 

401 xy_range = legacy_function2.getXYRange() 

402 bbox = Box.factory[ 

403 _require_int(xy_range.y.min + 0.5) : _require_int(xy_range.y.max + 0.5), 

404 _require_int(xy_range.x.min + 0.5) : _require_int(xy_range.x.max + 0.5), 

405 ] 

406 if bounds is not None: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 if bounds.bbox != bbox: 

408 raise ValueError( 

409 "Custom bounds when converting a Chebyshev background must not change the bbox." 

410 ) 

411 else: 

412 bounds = bbox 

413 order = legacy_function2.getOrder() 

414 coefficients = np.zeros((order + 1, order + 1), dtype=np.float64) 

415 for i, pq in ChebyshevField._legacy_function2_indices(order): 

416 coefficients[pq.y, pq.x] = legacy_function2.getParameter(i) 

417 return ChebyshevField(bbox, coefficients, unit=unit) 

418 

419 def to_legacy_function2(self) -> LegacyChebyshev1Function2D: 

420 """Convert to a legacy `lsst.afw.math.Chebyshev1Function2D`.""" 

421 from lsst.afw.math import Chebyshev1Function2D as LegacyChebyshev1Function2D 

422 from lsst.geom import Box2D as LegacyBox2D 

423 

424 order = max(self.y_order, self.x_order) 

425 result = LegacyChebyshev1Function2D(order, LegacyBox2D(self.bounds.bbox.to_legacy())) 

426 for i, pq in self._legacy_function2_indices(order): 

427 result.setParameter( 

428 i, 

429 ( 

430 self._coefficients[pq.y, pq.x] 

431 if pq.y < self._coefficients.shape[0] and pq.x < self._coefficients.shape[1] 

432 else 0.0 

433 ), 

434 ) 

435 return result 

436 

437 @staticmethod 

438 def _legacy_function2_indices(order: int) -> Iterator[tuple[int, YX[int]]]: 

439 i = 0 

440 for n in range(order + 1): 

441 for p in range(0, n + 1): 

442 q = n - p 

443 yield i, YX(y=p, x=q) 

444 i += 1 

445 

446 def _remap(self, *, x: np.ndarray, y: np.ndarray) -> YX[np.ndarray]: 

447 x -= self._xt 

448 x *= self._xs 

449 y -= self._yt 

450 y *= self._ys 

451 return YX(y=y, x=x) 

452 

453 def _packing_indices(self, triangular: bool) -> Iterator[tuple[int, YX[int]]]: 

454 i = 0 

455 for p in range(self.y_order + 1): 

456 for q in range(self.x_order + 1): 

457 if not triangular or p + q <= self.order: 

458 yield i, YX(y=p, x=q) 

459 i += 1 

460 

461 def _make_grid_matrix(self, *, x: np.ndarray, y: np.ndarray, triangular: bool) -> np.ndarray: 

462 r = self._remap( 

463 x=np.asarray(x, dtype=np.float64, copy=True), 

464 y=np.asarray(y, dtype=np.float64, copy=True), 

465 ) 

466 yv = np.polynomial.chebyshev.chebvander(r.y, self.y_order) 

467 xv = np.polynomial.chebyshev.chebvander(r.x, self.x_order) 

468 indices = list(self._packing_indices(triangular)) 

469 tensor = np.zeros(r.y.shape + r.x.shape + (len(indices),), dtype=np.float64) 

470 for i, pq in indices: 

471 tensor[:, :, i] = np.multiply.outer(yv[:, pq.y], xv[:, pq.x]) 

472 return tensor.reshape(y.shape[0] * x.shape[0], len(indices)) 

473 

474 def _make_general_matrix(self, *, x: np.ndarray, y: np.ndarray, triangular: bool) -> np.ndarray: 

475 r = self._remap( 

476 x=np.asarray(x, dtype=np.float64, copy=True).ravel(), 

477 y=np.asarray(y, dtype=np.float64, copy=True).ravel(), 

478 ) 

479 yv = np.polynomial.chebyshev.chebvander(r.y, self.y_order) 

480 xv = np.polynomial.chebyshev.chebvander(r.x, self.x_order) 

481 indices = list(self._packing_indices(triangular)) 

482 matrix = np.zeros(r.y.shape + (len(indices),), dtype=np.float64) 

483 for i, pq in indices: 

484 matrix[:, i] = yv[:, pq.y] * xv[:, pq.x] 

485 return matrix 

486 

487 

488class ChebyshevFieldSerializationModel(ArchiveTree): 

489 """Serialization model for `ChebyshevField`.""" 

490 

491 SCHEMA_NAME: ClassVar[str] = "chebyshev_field" 

492 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

493 MIN_READ_VERSION: ClassVar[int] = 1 

494 PUBLIC_TYPE: ClassVar[type] = ChebyshevField 

495 

496 bounds: BoundsSerializationModel = pydantic.Field( 

497 description=( 

498 "The region where this field can be evaluated. " 

499 "The bbox of this region is grown by half a pixel on all sides and then used to remap " 

500 "coordinates to [-1, 1]x[-1, 1], which is the natural domain of a 2-d Chebyshev polynomial." 

501 ) 

502 ) 

503 

504 coefficients: InlineArray = pydantic.Field( 

505 description=( 

506 "Coefficients for a 2-d Chebyshev polynomial of the first kind, as a 2-d matrix in which " 

507 "element [p, q] corresponds to the coefficient of T_p(y) T_q(x)." 

508 ) 

509 ) 

510 

511 unit: Unit | None = pydantic.Field(default=None, description="Units of the field.") 

512 

513 field_type: Literal["CHEBYSHEV"] = "CHEBYSHEV" 

514 

515 def deserialize(self, archive: InputArchive, **kwargs: Any) -> ChebyshevField: 

516 """Deserialize the Chebyshev field from an input archive. 

517 

518 Parameters 

519 ---------- 

520 archive 

521 Archive to read from. 

522 **kwargs 

523 Unsupported keyword arguments are accepted only to provide 

524 better error messages (raising 

525 `.serialization.InvalidParameterError`). 

526 """ 

527 if kwargs: 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true

528 raise InvalidParameterError(f"Unrecognized parameters for ChebyshevField: {set(kwargs.keys())}.") 

529 return ChebyshevField(self.bounds.deserialize(), self.coefficients, unit=self.unit) 

530 

531 

532def _require_int(v: float) -> int: 

533 if (z := int(v)) == v: 533 ↛ 535line 533 didn't jump to line 535 because the condition on line 533 was always true

534 return z 

535 raise ValueError("Legacy Chebyshev1Function2 XY range must be at half-integer positions.")