Coverage for src/rok4/style.py: 90%

177 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-01 15:35 +0000

1"""Provide classes to use a ROK4 style. 

2 

3The module contains the following classe: 

4 

5- `Style` - Style descriptor, to convert raster data 

6 

7Loading a style requires environment variables : 

8 

9- ROK4_STYLES_DIRECTORY 

10""" 

11 

12# -- IMPORTS -- 

13 

14# standard library 

15import json 

16import os 

17from json.decoder import JSONDecodeError 

18from typing import Dict, Tuple 

19 

20from rok4.enums import ColorFormat 

21 

22# package 

23from rok4.exceptions import FormatError, MissingAttributeError, MissingEnvironmentError 

24from rok4.storage import exists, get_data_str 

25 

26DEG_TO_RAD = 0.0174532925199432958 

27 

28 

29class Colour: 

30 """A palette's RGBA colour. 

31 

32 Attributes: 

33 value (float): Value to convert to RGBA 

34 red (int): Red value (from 0 to 255) 

35 green (int): Green value (from 0 to 255) 

36 blue (int): Blue value (from 0 to 255) 

37 alpha (int): Alpha value (from 0 to 255) 

38 """ 

39 

40 def __init__(self, palette: Dict, style: "Style") -> None: 

41 """Constructor method 

42 

43 Args: 

44 colour: Colour attributes, according to JSON structure 

45 style: Style object containing the palette's colour to create 

46 

47 Examples: 

48 

49 JSON colour section 

50 

51 { 

52 "value": 600, 

53 "red": 220, 

54 "green": 179, 

55 "blue": 99, 

56 "alpha": 255 

57 } 

58 

59 Raises: 

60 MissingAttributeError: Attribute is missing in the content 

61 Exception: Invalid colour's band 

62 """ 

63 

64 try: 

65 self.value = palette["value"] 

66 

67 self.red = palette["red"] 

68 if self.red < 0 or self.red > 255: 

69 raise Exception( 

70 f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)" 

71 ) 

72 self.green = palette["green"] 

73 if self.green < 0 or self.green > 255: 

74 raise Exception( 

75 f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)" 

76 ) 

77 self.blue = palette["blue"] 

78 if self.blue < 0 or self.blue > 255: 

79 raise Exception( 

80 f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)" 

81 ) 

82 self.alpha = palette["alpha"] 

83 if self.alpha < 0 or self.alpha > 255: 

84 raise Exception( 

85 f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)" 

86 ) 

87 

88 except KeyError as e: 

89 raise MissingAttributeError(style.path, f"palette.colours[].{e}") 

90 

91 except TypeError: 

92 raise Exception( 

93 f"In style '{style.path}', a palette colour band has an invalid value (integer between 0 and 255 expected)" 

94 ) 

95 

96 @property 

97 def rgba(self) -> Tuple[int]: 

98 return (self.red, self.green, self.blue, self.alpha) 

99 

100 @property 

101 def rgb(self) -> Tuple[int]: 

102 return (self.red, self.green, self.blue) 

103 

104 

105class Palette: 

106 """A style's RGBA palette. 

107 

108 Attributes: 

109 no_alpha (bool): Colour without alpha band 

110 rgb_continuous (bool): Continuous RGB values ? 

111 alpha_continuous (bool): Continuous alpha values ? 

112 colours (List[Colour]): Palette's colours, input values ascending 

113 """ 

114 

115 def __init__(self, palette: Dict, style: "Style") -> None: 

116 """Constructor method 

117 

118 Args: 

119 palette: Palette attributes, according to JSON structure 

120 style: Style object containing the palette to create 

121 

122 Examples: 

123 

124 JSON palette section 

125 

126 { 

127 "no_alpha": false, 

128 "rgb_continuous": true, 

129 "alpha_continuous": true, 

130 "colours": [ 

131 { "value": -99999, "red": 255, "green": 255, "blue": 255, "alpha": 0 }, 

132 { "value": -99998.1, "red": 255, "green": 255, "blue": 255, "alpha": 0 }, 

133 { "value": -99998.0, "red": 255, "green": 0, "blue": 255, "alpha": 255 }, 

134 { "value": -501, "red": 255, "green": 0, "blue": 255, "alpha": 255 }, 

135 { "value": -500, "red": 1, "green": 29, "blue": 148, "alpha": 255 }, 

136 { "value": -15, "red": 19, "green": 42, "blue": 255, "alpha": 255 }, 

137 { "value": 0, "red": 67, "green": 105, "blue": 227, "alpha": 255 }, 

138 { "value": 0.01, "red": 57, "green": 151, "blue": 105, "alpha": 255 }, 

139 { "value": 300, "red": 230, "green": 230, "blue": 128, "alpha": 255 }, 

140 { "value": 600, "red": 220, "green": 179, "blue": 99, "alpha": 255 }, 

141 { "value": 2000, "red": 162, "green": 100, "blue": 51, "alpha": 255 }, 

142 { "value": 2500, "red": 122, "green": 81, "blue": 40, "alpha": 255 }, 

143 { "value": 3000, "red": 255, "green": 255, "blue": 255, "alpha": 255 }, 

144 { "value": 9000, "red": 255, "green": 255, "blue": 255, "alpha": 255 }, 

145 { "value": 9001, "red": 255, "green": 255, "blue": 255, "alpha": 255 } 

146 ] 

147 } 

148 

149 Raises: 

150 MissingAttributeError: Attribute is missing in the content 

151 Exception: No colour in the palette or invalid colour 

152 """ 

153 

154 try: 

155 self.no_alpha = palette["no_alpha"] 

156 self.rgb_continuous = palette["rgb_continuous"] 

157 self.alpha_continuous = palette["alpha_continuous"] 

158 

159 self.colours = [] 

160 for colour in palette["colours"]: 

161 self.colours.append(Colour(colour, style)) 

162 if len(self.colours) >= 2 and self.colours[-1].value <= self.colours[-2].value: 

163 raise Exception( 

164 f"Style '{style.path}' palette colours hav eto be ordered input value ascending" 

165 ) 

166 

167 if len(self.colours) == 0: 

168 raise Exception(f"Style '{style.path}' palette has no colour") 

169 

170 except KeyError as e: 

171 raise MissingAttributeError(style.path, f"palette.{e}") 

172 

173 def convert(self, value: float) -> Tuple[int]: 

174 

175 # Les couleurs dans la palette sont rangées par valeur croissante 

176 # On commence par gérer les cas où la valeur est en dehors de la palette 

177 

178 if value <= self.colours[0].value: 

179 if self.no_alpha: 

180 return self.colours[0].rgb 

181 else: 

182 return self.colours[0].rgba 

183 

184 if value >= self.colours[-1].value: 

185 if self.no_alpha: 

186 return self.colours[-1].rgb 

187 else: 

188 return self.colours[-1].rgba 

189 

190 # On va maintenant chercher les deux couleurs entre lesquelles la valeur est 

191 for i in range(1, len(self.colours)): 

192 if self.colours[i].value < value: 

193 continue 

194 

195 # on est sur la première couleur de valeur supérieure 

196 colour_inf = self.colours[i - 1] 

197 colour_sup = self.colours[i] 

198 break 

199 

200 ratio = (value - colour_inf.value) / (colour_sup.value - colour_inf.value) 

201 if self.rgb_continuous: 

202 pixel = ( 

203 colour_inf.red + ratio * (colour_sup.red - colour_inf.red), 

204 colour_inf.green + ratio * (colour_sup.green - colour_inf.green), 

205 colour_inf.blue + ratio * (colour_sup.blue - colour_inf.blue), 

206 ) 

207 else: 

208 pixel = (colour_inf.red, colour_inf.green, colour_inf.blue) 

209 

210 if self.no_alpha: 

211 return pixel 

212 else: 

213 if self.alpha_continuous: 

214 return pixel + (colour_inf.alpha + ratio * (colour_sup.alpha - colour_inf.alpha),) 

215 else: 

216 return pixel + (colour_inf.alpha,) 

217 

218 

219class Slope: 

220 """A style's slope parameters. 

221 

222 Attributes: 

223 algo (str): Slope calculation algorithm chosen by the user ("H" for Horn) 

224 unit (str): Slope unit 

225 image_nodata (float): Nodata input value 

226 slope_nodata (float): Nodata slope value 

227 slope_max (float): Maximum value for the slope 

228 """ 

229 

230 def __init__(self, slope: Dict, style: "Style") -> None: 

231 """Constructor method 

232 

233 Args: 

234 slope: Slope attributes, according to JSON structure 

235 style: Style object containing the slope to create 

236 

237 Examples: 

238 

239 JSON pente section 

240 

241 { 

242 "algo": "H", 

243 "unit": "degree", 

244 "image_nodata": -99999, 

245 "slope_nodata": 91, 

246 "slope_max": 90 

247 } 

248 

249 Raises: 

250 MissingAttributeError: Attribute is missing in the content 

251 """ 

252 

253 try: 

254 self.algo = slope.get("algo", "H") 

255 self.unit = slope.get("unit", "degree") 

256 self.image_nodata = slope.get("image_nodata", -99999) 

257 self.slope_nodata = slope.get("slope_nodata", 0) 

258 self.slope_max = slope.get("slope_max", 90) 

259 except KeyError as e: 

260 raise MissingAttributeError(style.path, f"pente.{e}") 

261 

262 

263class Exposition: 

264 """A style's exposition parameters. 

265 

266 Attributes: 

267 algo (str): Slope calculation algorithm chosen by the user ("H" for Horn) 

268 min_slope (int): Slope from which exposition is computed 

269 image_nodata (float): Nodata input value 

270 exposition_nodata (float): Nodata exposition value 

271 """ 

272 

273 def __init__(self, exposition: Dict, style: "Style") -> None: 

274 """Constructor method 

275 

276 Args: 

277 exposition: Exposition attributes, according to JSON structure 

278 style: Style object containing the exposition to create 

279 

280 Examples: 

281 

282 JSON exposition section 

283 

284 { 

285 "algo": "H", 

286 "min_slope": 1 

287 } 

288 

289 Raises: 

290 MissingAttributeError: Attribute is missing in the content 

291 """ 

292 

293 try: 

294 self.algo = exposition.get("algo", "H") 

295 self.min_slope = exposition.get("min_slope", 1.0) * DEG_TO_RAD 

296 self.image_nodata = exposition.get("min_slope", -99999) 

297 self.exposition_nodata = exposition.get("aspect_nodata", -1) 

298 except KeyError as e: 

299 raise MissingAttributeError(style.path, f"exposition.{e}") 

300 

301 

302class Estompage: 

303 """A style's estompage parameters. 

304 

305 Attributes: 

306 zenith (float): Sun's zenith in degree 

307 azimuth (float): Sun's azimuth in degree 

308 z_factor (int): Slope exaggeration factor 

309 image_nodata (float): Nodata input value 

310 estompage_nodata (float): Nodata estompage value 

311 """ 

312 

313 def __init__(self, estompage: Dict, style: "Style") -> None: 

314 """Constructor method 

315 

316 Args: 

317 estompage: Estompage attributes, according to JSON structure 

318 style: Style object containing the estompage to create 

319 

320 Examples: 

321 

322 JSON estompage section 

323 

324 { 

325 "zenith": 45, 

326 "azimuth": 315, 

327 "z_factor": 1 

328 } 

329 

330 Raises: 

331 MissingAttributeError: Attribute is missing in the content 

332 """ 

333 

334 try: 

335 # azimuth et azimuth sont converti en leur complémentaire en radian 

336 self.zenith = (90.0 - estompage.get("zenith", 45)) * DEG_TO_RAD 

337 self.azimuth = (360.0 - estompage.get("azimuth", 315)) * DEG_TO_RAD 

338 self.z_factor = estompage.get("z_factor", 1) 

339 self.image_nodata = estompage.get("image_nodata", -99999.0) 

340 self.estompage_nodata = estompage.get("estompage_nodata", 0.0) 

341 except KeyError as e: 

342 raise MissingAttributeError(style.path, f"estompage.{e}") 

343 

344 

345class Legend: 

346 """A style's legend. 

347 

348 Attributes: 

349 format (str): Legend image's mime type 

350 url (str): Legend image's url 

351 height (int): Legend image's pixel height 

352 width (int): Legend image's pixel width 

353 min_scale_denominator (int): Minimum scale at which the legend is applicable 

354 max_scale_denominator (int): Maximum scale at which the legend is applicable 

355 """ 

356 

357 def __init__(self, legend: Dict, style: "Style") -> None: 

358 """Constructor method 

359 

360 Args: 

361 legend: Legend attributes, according to JSON structure 

362 style: Style object containing the legend to create 

363 

364 Examples: 

365 

366 JSON legend section 

367 

368 { 

369 "format": "image/png", 

370 "url": "http://ign.fr", 

371 "height": 100, 

372 "width": 100, 

373 "min_scale_denominator": 0, 

374 "max_scale_denominator": 30 

375 } 

376 

377 Raises: 

378 MissingAttributeError: Attribute is missing in the content 

379 """ 

380 

381 try: 

382 self.format = legend["format"] 

383 self.url = legend["url"] 

384 self.height = legend["height"] 

385 self.width = legend["width"] 

386 self.min_scale_denominator = legend["min_scale_denominator"] 

387 self.max_scale_denominator = legend["max_scale_denominator"] 

388 except KeyError as e: 

389 raise MissingAttributeError(style.path, f"legend.{e}") 

390 

391 

392class Style: 

393 """A raster data style 

394 

395 Attributes: 

396 path (str): TMS origin path (JSON) 

397 id (str): Style's technical identifier 

398 identifier (str): Style's public identifier 

399 title (str): Style's title 

400 abstract (str): Style's abstract 

401 keywords (List[str]): Style's keywords 

402 legend (Legend): Style's legend 

403 

404 palette (Palette): Style's palette, optionnal 

405 estompage (Estompage): Style's estompage parameters, optionnal 

406 slope (Slope): Style's slope parameters, optionnal 

407 exposition (Exposition): Style's exposition parameters, optionnal 

408 

409 """ 

410 

411 def __init__(self, id: str) -> None: 

412 """Constructor method 

413 

414 Style's directory is defined with environment variable ROK4_STYLES_DIRECTORY. Provided id is used as file/object name, with pr without JSON extension 

415 

416 Args: 

417 path: Style's id 

418 

419 Raises: 

420 MissingEnvironmentError: Missing object storage informations 

421 StorageError: Storage read issue 

422 FileNotFoundError: Style file or object does not exist, with or without extension 

423 FormatError: Provided path is not a well formed JSON 

424 MissingAttributeError: Attribute is missing in the content 

425 Exception: No colour in the palette or invalid colour 

426 """ 

427 

428 self.id = id 

429 

430 try: 

431 self.path = os.path.join(os.environ["ROK4_STYLES_DIRECTORY"], f"{self.id}") 

432 if not exists(self.path): 

433 self.path = os.path.join(os.environ["ROK4_STYLES_DIRECTORY"], f"{self.id}.json") 

434 if not exists(self.path): 

435 raise FileNotFoundError(f"{self.path}, even without extension") 

436 except KeyError as e: 

437 raise MissingEnvironmentError(e) 

438 

439 try: 

440 data = json.loads(get_data_str(self.path)) 

441 

442 self.identifier = data["identifier"] 

443 self.title = data["title"] 

444 self.abstract = data["abstract"] 

445 self.keywords = data["keywords"] 

446 

447 self.legend = Legend(data["legend"], self) 

448 

449 if "palette" in data: 

450 self.palette = Palette(data["palette"], self) 

451 else: 

452 self.palette = None 

453 

454 if "estompage" in data: 

455 self.estompage = Estompage(data["estompage"], self) 

456 else: 

457 self.estompage = None 

458 

459 if "pente" in data: 

460 self.slope = Slope(data["pente"], self) 

461 else: 

462 self.slope = None 

463 

464 if "exposition" in data: 

465 self.exposition = Exposition(data["exposition"], self) 

466 else: 

467 self.exposition = None 

468 

469 except JSONDecodeError as e: 

470 raise FormatError("JSON", self.path, e) 

471 

472 except KeyError as e: 

473 raise MissingAttributeError(self.path, e) 

474 

475 @property 

476 def bands(self) -> int: 

477 """Bands count after style application 

478 

479 Returns: 

480 int: Bands count after style application, None if style is identity 

481 """ 

482 if self.palette is not None: 

483 if self.palette.no_alpha: 

484 return 3 

485 else: 

486 return 4 

487 

488 elif self.estompage is not None or self.exposition is not None or self.slope is not None: 

489 return 1 

490 

491 else: 

492 return None 

493 

494 @property 

495 def format(self) -> ColorFormat: 

496 """Bands format after style application 

497 

498 Returns: 

499 ColorFormat: Bands format after style application, None if style is identity 

500 """ 

501 if self.palette is not None: 

502 return ColorFormat.UINT8 

503 

504 elif self.estompage is not None or self.exposition is not None or self.slope is not None: 

505 return ColorFormat.FLOAT32 

506 

507 else: 

508 return None 

509 

510 @property 

511 def input_nodata(self) -> float: 

512 """Input nodata value 

513 

514 Returns: 

515 float: Input nodata value, None if style is identity 

516 """ 

517 

518 if self.estompage is not None: 

519 return self.estompage.image_nodata 

520 elif self.exposition is not None: 

521 return self.exposition.image_nodata 

522 elif self.slope is not None: 

523 return self.slope.image_nodata 

524 elif self.palette is not None: 

525 return self.palette.colours[0].value 

526 else: 

527 return None 

528 

529 @property 

530 def is_identity(self) -> bool: 

531 """Is style identity 

532 

533 Returns: 

534 bool: Is style identity 

535 """ 

536 

537 return ( 

538 self.estompage is None 

539 and self.exposition is None 

540 and self.slope is None 

541 and self.palette is None 

542 )