Coverage for python/lsst/images/_backgrounds.py: 56%

80 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. 

11from __future__ import annotations 

12 

13__all__ = ("Background", "BackgroundMap", "BackgroundMapSerializationModel") 

14 

15import dataclasses 

16import sys 

17from collections.abc import Iterable, Iterator, Mapping 

18from typing import Any, ClassVar, cast, final 

19 

20import pydantic 

21 

22from .describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField 

23from .fields import Field, FieldSerializationModel 

24from .serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

25 

26 

27@dataclasses.dataclass(frozen=True) 

28class Background: 

29 """A named background model and optional description.""" 

30 

31 name: str 

32 """A unique name for this background.""" 

33 

34 field: Field 

35 """The actual background model itself.""" 

36 

37 description: str = "" 

38 """A description of how the background model was produced and/or how it 

39 should be used. 

40 """ 

41 

42 

43class BackgroundMap(DescribableMixin, Mapping[str, Background]): 

44 """A mapping of background models associated with an image. 

45 

46 Unlike most image characterization objects, the best background model 

47 often depends on the science case, and hence we may want to associate more 

48 than one with an image. 

49 

50 Parameters 

51 ---------- 

52 backgrounds 

53 Background models to include in the map, keyed by their name. 

54 subtracted 

55 Name of the background that has been subtracted from the image, or 

56 `None` if no background has been subtracted. 

57 """ 

58 

59 def __init__(self, backgrounds: Iterable[Background] = (), subtracted: str | None = None) -> None: 

60 self._backgrounds = {b.name: b for b in backgrounds} 

61 self._subtracted = subtracted 

62 if isinstance(self._subtracted, str) and self._subtracted not in self._backgrounds: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 raise KeyError(f"Subtracted background {self._subtracted!r} not present in map.") 

64 

65 @property 

66 def subtracted(self) -> Background | None: 

67 """The background subtracted from this image (`Background` | `None`). 

68 

69 Notes 

70 ----- 

71 If `None`, none of the backgrounds in this map were subtracted from 

72 the image. This does not necessarily mean no background at all was 

73 subtracted (e.g. in a coadd, backgrounds are generally subtracted from 

74 the input images before they are combined, and the sum of those 

75 backgrounds may not be available in a coadd background map.) 

76 """ 

77 if self._subtracted is None: 

78 return None 

79 return self._backgrounds[self._subtracted] 

80 

81 def __iter__(self) -> Iterator[str]: 

82 return iter(self._backgrounds.keys()) 

83 

84 def __getitem__(self, key: str) -> Background: 

85 return self._backgrounds[key] 

86 

87 def __len__(self) -> int: 

88 return len(self._backgrounds) 

89 

90 if "sphinx" in sys.modules: 

91 # The Python standard library docstring is not valid reStructuredText, 

92 # but the true signature (with involves overloads) is complicated. 

93 def get[V](self, key: str, default: V | None = None) -> Background | V | None: # type: ignore 

94 """Return the background with the given key or the given default 

95 value. 

96 """ 

97 return super().get(key, default) 

98 

99 def copy(self) -> BackgroundMap: 

100 """Return a copy of the background map.""" 

101 return BackgroundMap(self.values(), self._subtracted) 

102 

103 def add(self, name: str, field: Field, description: str = "", *, is_subtracted: bool = False) -> None: 

104 """Add a new background to the map. 

105 

106 Parameters 

107 ---------- 

108 name 

109 Unique name for this background model. 

110 field 

111 The background field itself. 

112 description 

113 A description of how this background model was produced and/or how 

114 it should be used. 

115 is_subtracted 

116 Whether this background is the one that was subtracted from the 

117 image this background map is attached to. 

118 

119 Notes 

120 ----- 

121 There are no guards against ``is_subtracted=True`` being passed for 

122 multiple different backgrounds; correctness is up to the caller. Note 

123 that we only allow one background to be subtracted at once 

124 (incremental backgrounds should be modeled via `.fields.SumField`, not 

125 multiple named entries in this map). 

126 """ 

127 if name in self._backgrounds: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 raise KeyError(f"A background with name {name!r} already exists.") 

129 self._backgrounds[name] = Background(name, field, description) 

130 if is_subtracted: 

131 self._subtracted = name 

132 

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

134 """Return a `Report` describing this background map. 

135 

136 Parameters 

137 ---------- 

138 options : `DescribeOptions`, optional 

139 Rendering options; forwarded to the background models. 

140 

141 Notes 

142 ----- 

143 The report is ``inline``, so a composite that holds a map shows only 

144 the summary line. The children below are what a map describes on its 

145 own, where the background models themselves are the point. 

146 """ 

147 subtracted_name = self._subtracted 

148 if not self._backgrounds: 

149 summary = "no backgrounds" 

150 else: 

151 summary = ", ".join( 

152 f"{name} (subtracted)" if name == subtracted_name else name for name in self._backgrounds 

153 ) 

154 children: dict[str, Report] = {} 

155 if not options.brief: 

156 child = options.for_child() 

157 for name, background in self._backgrounds.items(): 

158 # The model builds its own report; putting the background's 

159 # attributes at the top of it keeps them beside the model they 

160 # describe, rather than behind another level of nesting. 

161 report = background.field._describe(child) 

162 attributes = [] 

163 if name == subtracted_name: 

164 attributes.append(ReportField(label="subtracted", value="yes", role=FieldRole.DERIVED)) 

165 if background.description: 

166 attributes.append( 

167 ReportField( 

168 label="description", 

169 value=background.description, 

170 role=FieldRole.DERIVED, 

171 ) 

172 ) 

173 report.fields[:0] = attributes 

174 children[name] = report 

175 return Report( 

176 type_name="BackgroundMap", 

177 summary=summary, 

178 children=children, 

179 inline=True, 

180 ) 

181 

182 def serialize(self, archive: OutputArchive[Any]) -> BackgroundMapSerializationModel: 

183 """Write a background map to an archive. 

184 

185 Parameters 

186 ---------- 

187 archive 

188 Archive to write to. 

189 """ 

190 result = BackgroundMapSerializationModel(subtracted=self._subtracted) 

191 for name, background in self.items(): 

192 result.fields[name] = cast( 

193 FieldSerializationModel, 

194 archive.serialize_direct(f"fields/{name}", background.field.serialize), 

195 ) 

196 result.descriptions[name] = background.description 

197 return result 

198 

199 

200@final 

201class BackgroundMapSerializationModel(ArchiveTree): 

202 """Serialization model for background maps.""" 

203 

204 SCHEMA_NAME: ClassVar[str] = "background_map" 

205 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

206 MIN_READ_VERSION: ClassVar[int] = 1 

207 PUBLIC_TYPE: ClassVar[type] = BackgroundMap 

208 

209 fields: dict[str, FieldSerializationModel] = pydantic.Field( 

210 default_factory=dict, 

211 description="Mapping from background model name to the model field itself.", 

212 ) 

213 

214 descriptions: dict[str, str] = pydantic.Field( 

215 default_factory=dict, 

216 description="Mapping from background model name to its description.", 

217 ) 

218 

219 subtracted: str | None = pydantic.Field( 

220 default=None, 

221 description="Name of the background that was subtracted, or None if no background was subtracted.", 

222 ) 

223 

224 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> BackgroundMap: 

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

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

227 return BackgroundMap( 

228 [ 

229 Background( 

230 name=name, field=field.deserialize(archive), description=self.descriptions.get(name, "") 

231 ) 

232 for name, field in self.fields.items() 

233 ], 

234 subtracted=self.subtracted, 

235 )