Coverage for src/CSET/operators/imageprocessing.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:49 +0000

1# © Crown copyright, Met Office (2022-2025) and CSET contributors. 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14 

15"""Operators to perform various kinds of image processing.""" 

16 

17import logging 

18 

19import iris 

20import iris.cube 

21import numpy as np 

22from skimage.metrics import structural_similarity 

23 

24from CSET._common import is_increasing 

25from CSET.operators._utils import ( 

26 ensure_common_time_points, 

27 fully_equalise_attributes, 

28 get_cube_yxcoordname, 

29 get_time_coord, 

30) 

31from CSET.operators.regrid import regrid_onto_cube 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36def _SSIM_cube_preparation( 

37 cubes: iris.cube.CubeList, 

38) -> (iris.cube.Cube, iris.cube.Cube, str): 

39 """Prepare the cubes for the SSIM calculation. 

40 

41 Parameters 

42 ---------- 

43 cubes: iris.cube.CubeList 

44 A list of exactly two cubes. One must have the cset_comparison_base 

45 attribute set to 1, and will be used as the base of the comparison. 

46 The cubes must contain a time coordinate. 

47 

48 Returns 

49 ------- 

50 iris.cube.Cube, iris.cube.Cube, str 

51 

52 Raises 

53 ------ 

54 ValueError 

55 When the cubes are not compatible, or no time coordinate. 

56 """ 

57 if len(cubes) != 2: 

58 raise ValueError("cubes should contain exactly 2 cubes.") 

59 base: iris.cube.Cube = cubes.extract_cube( 

60 iris.AttributeConstraint(cset_comparison_base=1) 

61 ) 

62 other: iris.cube.Cube = cubes.extract_cube( 

63 iris.Constraint( 

64 cube_func=lambda cube: "cset_comparison_base" not in cube.attributes 

65 ) 

66 ) 

67 

68 # Get spatial coord names. 

69 base_lat_name, base_lon_name = get_cube_yxcoordname(base) 

70 other_lat_name, other_lon_name = get_cube_yxcoordname(other) 

71 

72 # Ensure cubes to compare are on common differencing grid. 

73 # This is triggered if either 

74 # i) latitude and longitude shapes are not the same. Note grid points 

75 # are not compared directly as these can differ through rounding 

76 # errors. 

77 # ii) or variables are known to often sit on different grid staggering 

78 # in different models (e.g. cell center vs cell edge), as is the case 

79 # for UM and LFRic comparisons. 

80 # In future greater choice of regridding method might be applied depending 

81 # on variable type. Linear regridding can in general be appropriate for smooth 

82 # variables. Care should be taken with interpretation of differences 

83 # given this dependency on regridding. 

84 if ( 

85 base.coord(base_lat_name).shape != other.coord(other_lat_name).shape 

86 or base.coord(base_lon_name).shape != other.coord(other_lon_name).shape 

87 ) or ( 

88 base.long_name 

89 in [ 

90 "eastward_wind_at_10m", 

91 "northward_wind_at_10m", 

92 "northward_wind_at_cell_centres", 

93 "eastward_wind_at_cell_centres", 

94 "zonal_wind_at_pressure_levels", 

95 "meridional_wind_at_pressure_levels", 

96 "potential_vorticity_at_pressure_levels", 

97 "vapour_specific_humidity_at_pressure_levels_for_climate_averaging", 

98 ] 

99 ): 

100 logger.debug("Linear regridding base cube to other grid to compute differences") 

101 base = regrid_onto_cube(base, other, method="Linear") 

102 

103 # Figure out if we are comparing between UM and LFRic; flip array if so. 

104 base_lat_direction = is_increasing(base.coord(base_lat_name).points) 

105 other_lat_direction = is_increasing(other.coord(other_lat_name).points) 

106 if base_lat_direction != other_lat_direction: 

107 other.data = np.flip(other.data, other.coord(other_lat_name).cube_dims(other)) 

108 

109 # Extract just common time points. 

110 # Equalise attributes so we can merge. 

111 fully_equalise_attributes([base, other]) 

112 logger.debug("Base: %s\nOther: %s", base, other) 

113 

114 # will raise if not common times 

115 time_coord = get_time_coord(base) 

116 if not time_coord: 

117 raise ValueError("Cubes should contain a time coordinate.") 

118 ensure_common_time_points(base, other) 

119 

120 # Create and empty CubeList for storing the time or realization data. 

121 return base, other, time_coord 

122 

123 

124def spatial_structural_similarity_model_comparisons( 

125 cubes: iris.cube.CubeList, sigma: float = 1.5 

126) -> iris.cube.Cube: 

127 r"""Calculate the structural similarity and produces a spatial plot. 

128 

129 Parameters 

130 ---------- 

131 cubes: iris.cube.CubeList 

132 A list of exactly two cubes. One must have the cset_comparison_base 

133 attribute set to 1, and will be used as the base of the comparison. 

134 The cubes must contain a time coordinate. 

135 sigma: float, optional 

136 The standard deviation of the Gaussian kernel to be used. The default 

137 is set to 1.5 to mimic the human eye following [Wangetal2004]_. 

138 

139 Returns 

140 ------- 

141 iris.cube.Cube 

142 

143 Raises 

144 ------ 

145 ValueError 

146 When the cubes are not compatible, or no time coordinate. 

147 

148 Notes 

149 ----- 

150 This diagnostic was introduced by Wang et al. (2004) [Wangetal2004]_. It is 

151 an image processing diagnostic that takes into account three factors asscoiated 

152 with an image: i) luminace, ii) contrast, iii) structure. In calculation terms 

153 it is a combination of the intensity, variance, and co-variance of an image. It is 

154 calculated as follows: 

155 

156 .. math:: SSIM(x,y) = \frac{(2\mu_{x}\mu_{y} + C_{1})(2\sigma_{xy} + C_{2})}{(\mu^{2}_{x}\mu^{2}_{y} + C_{1})(\sigma^{2}_{x}\sigma^{2}_{y} + C_{2})} 

157 

158 for images, x and y, and small constancts C1 and C2, with the other symbols having 

159 their usual statistical meaning. 

160 

161 The diagnostic varies between positive and negative one, on the most part. 

162 However, should the data being compared lie outside of the specified data 

163 range values larger or smaller can occur. Values close to or exactly 1 imply 

164 a perceptably similar image; values close to or exactly -1 imply an anticorrelated 

165 image; small values imply the fields are perceptably different. 

166 

167 The diagnostic has been setup with default values that are designed to mimic the 

168 human eye and so are universally applicable irrespective of model resolution. 

169 However, it should be noted that the mean structural similarity is not 

170 identical to the domain mean of the structural similarity. This difference 

171 occurs because the former is calculated over the mean of all the windows 

172 (Gaussian kernels) rather than the mean of the grid boxes. 

173 

174 Further details, including caveats, can be found in Wang et al. (2004) 

175 [Wangetal2004]_. 

176 

177 Examples 

178 -------- 

179 >>> SSIM = imageprocessing.spatial_structural_similarity_model_comparisons( 

180 cubes, sigma=1.5) 

181 >>> iplt.pcolormesh(SSIM[0,:], cmap=mpl.cm.bwr) 

182 >>> plt.gca().coastlines('10m') 

183 >>> plt.clim(-1, 1) 

184 >>> plt.colorbar() 

185 >>> plt.show() 

186 """ 

187 base, other, time_coord = _SSIM_cube_preparation(cubes) 

188 ssim = iris.cube.CubeList() 

189 

190 # Loop over realization and time coordinates. 

191 for base_r, other_r in zip( 

192 base.slices_over("realization"), 

193 other.slices_over("realization"), 

194 strict=True, 

195 ): 

196 for base_t, other_t in zip( 

197 base_r.slices_over(time_coord), other_r.slices_over(time_coord), strict=True 

198 ): 

199 # Use the full array as output will be as a 2D map. 

200 ssim_map = base_t.copy() 

201 _, ssim_map.data = structural_similarity( 

202 other_t.data, 

203 base_t.data, 

204 data_range=base_t.data.max() - base_t.data.min(), 

205 gaussian_weights=True, 

206 sigma=sigma, 

207 full=True, 

208 ) 

209 ssim.append(ssim_map) 

210 # Merge the cube slices into one cube, rename, and change units. 

211 ssim = ssim.merge_cube() 

212 ssim.standard_name = None 

213 ssim.long_name = "structural_similarity" 

214 ssim.units = "1" 

215 return ssim 

216 

217 

218def mean_structural_similarity_model_comparisons( 

219 cubes: iris.cube.CubeList, sigma: float = 1.5 

220) -> iris.cube.Cube: 

221 r"""Calculate the mean structural similarity and produces a timeseries. 

222 

223 Parameters 

224 ---------- 

225 cubes: iris.cube.CubeList 

226 A list of exactly two cubes. One must have the cset_comparison_base 

227 attribute set to 1, and will be used as the base of the comparison. 

228 The cubes must contain a time coordinate. 

229 sigma: float, optional 

230 The standard deviation of the Gaussian kernel to be used. The default 

231 is set to 1.5 to mimic the human eye following [Wangetal2004a]_. 

232 

233 Returns 

234 ------- 

235 iris.cube.Cube 

236 

237 Raises 

238 ------ 

239 ValueError 

240 When the cubes are not compatible, or no time coordinate. 

241 

242 Notes 

243 ----- 

244 This diagnostic was introduced by Wang et al. (2004) [Wangetal2004a]_. It is 

245 an image processing diagnostic that takes into account three factors asscoiated 

246 with an image: i) luminace, ii) contrast, iii) structure. In calculation terms 

247 it is a combination of the intensity, variance, and co-variance of an image. It is 

248 calculated as follows: 

249 

250 .. math:: SSIM(x,y) = \frac{(2\mu_{x}\mu_{y} + C_{1})(2\sigma_{xy} + C_{2})}{(\mu^{2}_{x}\mu^{2}_{y} + C_{1})(\sigma^{2}_{x}\sigma^{2}_{y} + C_{2})} 

251 

252 for images, x and y, and small constancts C1 and C2, with the other symbols having 

253 their usual statistical meaning. 

254 

255 The diagnostic varies between positive and negative one, on the most part. 

256 However, should the data being compared lie outside of the specified data 

257 range values larger or smaller can occur. Values close to or exactly 1 imply 

258 a perceptably similar image; values close to or exactly -1 imply an anticorrelated 

259 image; small values imply the fields are perceptably different. 

260 

261 The diagnostic has been setup with default values that are designed to mimic the 

262 human eye and so are universally applicable irrespective of model resolution. 

263 However, it should be noted that the mean structural similarity is not 

264 identical to the domain mean of the structural similarity. This difference 

265 occurs because the former is calculated over the mean of all the windows 

266 (Gaussian kernels) rather than the mean of the grid boxes. 

267 

268 Further details, including caveats, can be found in Wang et al. (2004) 

269 [Wangetal2004a]_. 

270 

271 Examples 

272 -------- 

273 >>> MSSIM = imageprocessing.structural_similarity_model_comparisons( 

274 cubes,sigma=1.5, spatial_plot=False) 

275 >>> iplt.plot(MSSIM) 

276 """ 

277 base, other, time_coord = _SSIM_cube_preparation(cubes) 

278 ssim = iris.cube.CubeList() 

279 

280 # Loop over realization and time coordinates. 

281 for base_r, other_r in zip( 

282 base.slices_over("realization"), 

283 other.slices_over("realization"), 

284 strict=True, 

285 ): 

286 for base_t, other_t in zip( 

287 base_r.slices_over(time_coord), other_r.slices_over(time_coord), strict=True 

288 ): 

289 # The MSSIM (Mean structural similarity) is compression to 

290 # a single point. Therefore, copying cube data for one 

291 # point in the domain to keep cube consistency. 

292 mssim = base_t[0, 0].copy() 

293 mssim.data = structural_similarity( 

294 other_t.data, 

295 base_t.data, 

296 data_range=base_t.data.max() - base_t.data.min(), 

297 gaussian_weights=True, 

298 sigma=sigma, 

299 ) 

300 ssim.append(mssim) 

301 # Merge the cube slices into one cube, rename, and change units. 

302 ssim = ssim.merge_cube() 

303 ssim.standard_name = None 

304 ssim.long_name = "structural_similarity" 

305 ssim.units = "1" 

306 return ssim