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

87 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-23 03:09 -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 

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_SUBTRACTED = "[SUBTRACTED]" 

27"""Marker naming the background that was subtracted from the image.""" 

28 

29 

30@dataclasses.dataclass(frozen=True) 

31class Background: 

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

33 

34 name: str 

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

36 

37 field: Field 

38 """The actual background model itself.""" 

39 

40 description: str = "" 

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

42 should be used. 

43 """ 

44 

45 

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

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

48 

49 Unlike most image characterization objects, the best background model 

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

51 than one with an image. 

52 

53 Parameters 

54 ---------- 

55 backgrounds 

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

57 subtracted 

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

59 `None` if no background has been subtracted. 

60 """ 

61 

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

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

64 self._subtracted = subtracted 

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

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

67 

68 @property 

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

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

71 

72 Notes 

73 ----- 

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

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

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

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

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

79 """ 

80 if self._subtracted is None: 

81 return None 

82 return self._backgrounds[self._subtracted] 

83 

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

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

86 

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

88 return self._backgrounds[key] 

89 

90 def __len__(self) -> int: 

91 return len(self._backgrounds) 

92 

93 if "sphinx" in sys.modules: 

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

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

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

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

98 value. 

99 """ 

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

101 

102 def copy(self) -> BackgroundMap: 

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

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

105 

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

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

108 

109 Parameters 

110 ---------- 

111 name 

112 Unique name for this background model. 

113 field 

114 The background field itself. 

115 description 

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

117 it should be used. 

118 is_subtracted 

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

120 image this background map is attached to. 

121 

122 Notes 

123 ----- 

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

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

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

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

128 multiple named entries in this map). 

129 """ 

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

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

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

133 if is_subtracted: 

134 self._subtracted = name 

135 

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

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

138 

139 Parameters 

140 ---------- 

141 options : `DescribeOptions`, optional 

142 Rendering options; forwarded to the background models. 

143 

144 Notes 

145 ----- 

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

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

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

149 

150 Uses bold text and an explicit annotations to indicate which background 

151 has been subtracted. 

152 """ 

153 subtracted_name = self._subtracted 

154 names = list(self._backgrounds) 

155 if not names: 

156 summary = "no backgrounds" 

157 elif subtracted_name is None: 

158 summary = f"{', '.join(names)} (none subtracted)" 

159 else: 

160 others = [name for name in names if name != subtracted_name] 

161 summary = f"**{subtracted_name} {_SUBTRACTED}**" 

162 if others: 

163 summary = f"{summary}; also: {', '.join(others)}" 

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

165 if not options.brief: 

166 child = options.for_child() 

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

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

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

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

171 report = background.field._describe(child) 

172 if name == subtracted_name: 

173 # Add marker to indicate this background was subtracted. 

174 report.title = f"{report._heading} **{_SUBTRACTED}**" 

175 report.emphasis_markup = True 

176 if background.description: 

177 report.fields.insert( 

178 0, 

179 ReportField( 

180 label="description", 

181 value=background.description, 

182 role=FieldRole.DERIVED, 

183 ), 

184 ) 

185 children[name] = report 

186 return Report( 

187 type_name="BackgroundMap", 

188 summary=summary, 

189 children=children, 

190 inline=True, 

191 emphasis_markup=subtracted_name is not None, 

192 ) 

193 

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

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

196 

197 Parameters 

198 ---------- 

199 archive 

200 Archive to write to. 

201 """ 

202 result = BackgroundMapSerializationModel(subtracted=self._subtracted) 

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

204 result.fields[name] = cast( 

205 FieldSerializationModel, 

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

207 ) 

208 result.descriptions[name] = background.description 

209 return result 

210 

211 

212@final 

213class BackgroundMapSerializationModel(ArchiveTree): 

214 """Serialization model for background maps.""" 

215 

216 SCHEMA_NAME: ClassVar[str] = "background_map" 

217 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

218 MIN_READ_VERSION: ClassVar[int] = 1 

219 PUBLIC_TYPE: ClassVar[type] = BackgroundMap 

220 

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

222 default_factory=dict, 

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

224 ) 

225 

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

227 default_factory=dict, 

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

229 ) 

230 

231 subtracted: str | None = pydantic.Field( 

232 default=None, 

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

234 ) 

235 

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

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

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

239 return BackgroundMap( 

240 [ 

241 Background( 

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

243 ) 

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

245 ], 

246 subtracted=self.subtracted, 

247 )