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
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-01 15:35 +0000
1"""Provide classes to use a ROK4 style.
3The module contains the following classe:
5- `Style` - Style descriptor, to convert raster data
7Loading a style requires environment variables :
9- ROK4_STYLES_DIRECTORY
10"""
12# -- IMPORTS --
14# standard library
15import json
16import os
17from json.decoder import JSONDecodeError
18from typing import Dict, Tuple
20from rok4.enums import ColorFormat
22# package
23from rok4.exceptions import FormatError, MissingAttributeError, MissingEnvironmentError
24from rok4.storage import exists, get_data_str
26DEG_TO_RAD = 0.0174532925199432958
29class Colour:
30 """A palette's RGBA colour.
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 """
40 def __init__(self, palette: Dict, style: "Style") -> None:
41 """Constructor method
43 Args:
44 colour: Colour attributes, according to JSON structure
45 style: Style object containing the palette's colour to create
47 Examples:
49 JSON colour section
51 {
52 "value": 600,
53 "red": 220,
54 "green": 179,
55 "blue": 99,
56 "alpha": 255
57 }
59 Raises:
60 MissingAttributeError: Attribute is missing in the content
61 Exception: Invalid colour's band
62 """
64 try:
65 self.value = palette["value"]
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 )
88 except KeyError as e:
89 raise MissingAttributeError(style.path, f"palette.colours[].{e}")
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 )
96 @property
97 def rgba(self) -> Tuple[int]:
98 return (self.red, self.green, self.blue, self.alpha)
100 @property
101 def rgb(self) -> Tuple[int]:
102 return (self.red, self.green, self.blue)
105class Palette:
106 """A style's RGBA palette.
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 """
115 def __init__(self, palette: Dict, style: "Style") -> None:
116 """Constructor method
118 Args:
119 palette: Palette attributes, according to JSON structure
120 style: Style object containing the palette to create
122 Examples:
124 JSON palette section
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 }
149 Raises:
150 MissingAttributeError: Attribute is missing in the content
151 Exception: No colour in the palette or invalid colour
152 """
154 try:
155 self.no_alpha = palette["no_alpha"]
156 self.rgb_continuous = palette["rgb_continuous"]
157 self.alpha_continuous = palette["alpha_continuous"]
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 )
167 if len(self.colours) == 0:
168 raise Exception(f"Style '{style.path}' palette has no colour")
170 except KeyError as e:
171 raise MissingAttributeError(style.path, f"palette.{e}")
173 def convert(self, value: float) -> Tuple[int]:
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
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
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
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
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
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)
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,)
219class Slope:
220 """A style's slope parameters.
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 """
230 def __init__(self, slope: Dict, style: "Style") -> None:
231 """Constructor method
233 Args:
234 slope: Slope attributes, according to JSON structure
235 style: Style object containing the slope to create
237 Examples:
239 JSON pente section
241 {
242 "algo": "H",
243 "unit": "degree",
244 "image_nodata": -99999,
245 "slope_nodata": 91,
246 "slope_max": 90
247 }
249 Raises:
250 MissingAttributeError: Attribute is missing in the content
251 """
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}")
263class Exposition:
264 """A style's exposition parameters.
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 """
273 def __init__(self, exposition: Dict, style: "Style") -> None:
274 """Constructor method
276 Args:
277 exposition: Exposition attributes, according to JSON structure
278 style: Style object containing the exposition to create
280 Examples:
282 JSON exposition section
284 {
285 "algo": "H",
286 "min_slope": 1
287 }
289 Raises:
290 MissingAttributeError: Attribute is missing in the content
291 """
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}")
302class Estompage:
303 """A style's estompage parameters.
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 """
313 def __init__(self, estompage: Dict, style: "Style") -> None:
314 """Constructor method
316 Args:
317 estompage: Estompage attributes, according to JSON structure
318 style: Style object containing the estompage to create
320 Examples:
322 JSON estompage section
324 {
325 "zenith": 45,
326 "azimuth": 315,
327 "z_factor": 1
328 }
330 Raises:
331 MissingAttributeError: Attribute is missing in the content
332 """
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}")
345class Legend:
346 """A style's legend.
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 """
357 def __init__(self, legend: Dict, style: "Style") -> None:
358 """Constructor method
360 Args:
361 legend: Legend attributes, according to JSON structure
362 style: Style object containing the legend to create
364 Examples:
366 JSON legend section
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 }
377 Raises:
378 MissingAttributeError: Attribute is missing in the content
379 """
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}")
392class Style:
393 """A raster data style
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
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
409 """
411 def __init__(self, id: str) -> None:
412 """Constructor method
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
416 Args:
417 path: Style's id
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 """
428 self.id = id
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)
439 try:
440 data = json.loads(get_data_str(self.path))
442 self.identifier = data["identifier"]
443 self.title = data["title"]
444 self.abstract = data["abstract"]
445 self.keywords = data["keywords"]
447 self.legend = Legend(data["legend"], self)
449 if "palette" in data:
450 self.palette = Palette(data["palette"], self)
451 else:
452 self.palette = None
454 if "estompage" in data:
455 self.estompage = Estompage(data["estompage"], self)
456 else:
457 self.estompage = None
459 if "pente" in data:
460 self.slope = Slope(data["pente"], self)
461 else:
462 self.slope = None
464 if "exposition" in data:
465 self.exposition = Exposition(data["exposition"], self)
466 else:
467 self.exposition = None
469 except JSONDecodeError as e:
470 raise FormatError("JSON", self.path, e)
472 except KeyError as e:
473 raise MissingAttributeError(self.path, e)
475 @property
476 def bands(self) -> int:
477 """Bands count after style application
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
488 elif self.estompage is not None or self.exposition is not None or self.slope is not None:
489 return 1
491 else:
492 return None
494 @property
495 def format(self) -> ColorFormat:
496 """Bands format after style application
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
504 elif self.estompage is not None or self.exposition is not None or self.slope is not None:
505 return ColorFormat.FLOAT32
507 else:
508 return None
510 @property
511 def input_nodata(self) -> float:
512 """Input nodata value
514 Returns:
515 float: Input nodata value, None if style is identity
516 """
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
529 @property
530 def is_identity(self) -> bool:
531 """Is style identity
533 Returns:
534 bool: Is style identity
535 """
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 )