# -*- coding: utf-8 -*-
# Land_cover_plotting.py
"""
Plotting and animating Digital Earth Australia Land Cover data.
License: The code in this notebook is licensed under the Apache License,
Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0). Digital Earth
Australia data is licensed under the Creative Commons by Attribution 4.0
license (https://creativecommons.org/licenses/by/4.0/).
Contact: If you need assistance, please post a question on the Open Data
Cube Discord chat (https://discord.com/invite/4hhBQVas5U) or on the GIS Stack
Exchange (https://gis.stackexchange.com/questions/ask?tags=open-data-cube)
using the `open-data-cube` tag (you can view previously asked questions
here: https://gis.stackexchange.com/questions/tagged/open-data-cube).
If you would like to report an issue with this script, you can file one
on GitHub (https://github.com/GeoscienceAustralia/dea-notebooks/issues/new).
Last modified: March 2025
"""
import numpy as np
import pandas as pd
# import ast
# import sys
from IPython.display import Image
import matplotlib.pyplot as plt
from matplotlib import colors as mcolours
from matplotlib import patheffects
from matplotlib.animation import FuncAnimation
# Define colour schemes for each land cover measurement
lc_colours = {
'level3': {111: (172, 188, 45, 255, "Cultivated\nTerrestrial\nVegetation"),
112: (14, 121, 18, 255, "Natural\nTerrestrial\nVegetation"),
124: (30, 191, 121, 255, "Natural\nAquatic\nVegetation"),
215: (218, 92, 105, 255, "Artificial\nSurface"),
216: (243, 171, 105, 255, "Natural\nBare\nSurface"),
220: (77, 159, 220, 255, "Water"),
255: (255, 255, 255, 255, "No Data")},
'level3_change_colour_scheme': {0: (255, 255, 255, 255, "No Change"),
111112: (14, 121, 18, 255, "CTV -> NTV"),
111215: (218, 92, 105, 255, "CTV -> AS"),
111216: (243, 171, 105, 255, "CTV -> BS"),
111220: (77, 159, 220, 255, "CTV -> Water"),
112111: (172, 188, 45, 255, "NTV -> CTV"),
112215: (218, 92, 105, 255, "NTV -> AS"),
112216: (243, 171, 105, 255, "NTV -> BS"),
112220: (77, 159, 220, 255, "NTV -> Water"),
124220: (77, 159, 220, 255, "NAV -> Water"),
215111: (172, 188, 45, 255, "AS -> CTV"),
215112: (14, 121, 18, 255, "AS -> NTV"),
215216: (243, 171, 105, 255, "AS -> BS"),
215220: (77, 159, 220, 255, "AS -> Water"),
216111: (172, 188, 45, 255, "BS -> CTV"),
216112: (14, 121, 18, 255, "BS -> NTV"),
216215: (218, 92, 105, 255, "BS -> AS"),
216220: (77, 159, 220, 255, "BS -> Water"),
220112: (14, 121, 18, 255, "Water -> NTV"),
220216: (243, 171, 105, 255, "Water -> BS"),
},
'level3_change_colour_bar': {111: (172, 188, 45, 255, "Changed to Cultivated\n Terrestrial Vegetation"),
112: (14, 121, 18, 255, "Changed to Natural\n Terrestrial Vegetation"),
124: (30, 191, 121, 255, "Changed to Natural\n Aquatic Vegetation"),
215: (218, 92, 105, 255, "Changed to Artificial\n Surface"),
216: (243, 171, 105, 255, "Changed to Natural\n Bare Surface"),
220: (77, 159, 220, 255, "Changed to Water"),
0: (255, 255, 255, 255, "No Change")},
'level4': {
1: (151, 187, 26, 255, 'Cultivated Terrestrial\n Vegetated:'),
2: (151, 187, 26, 255, 'Cultivated Terrestrial\n Vegetated: Woody'),
3: (209, 224, 51, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous'),
4: (197, 168, 71, 255, 'Cultivated Terrestrial\n Vegetated: Closed\n (> 65 %)'),
5: (205, 181, 75, 255, 'Cultivated Terrestrial\n Vegetated: Open\n (40 to 65 %)'),
6: (213, 193, 79, 255, 'Cultivated Terrestrial\n Vegetated: Open\n (15 to 40 %)'),
7: (228, 210, 108, 255, 'Cultivated Terrestrial\n Vegetated: Sparse\n (4 to 15 %)'),
8: (242, 227, 138, 255, 'Cultivated Terrestrial\n Vegetated: Scattered\n (1 to 4 %)'),
9: (197, 168, 71, 255, 'Cultivated Terrestrial\n Vegetated: Woody Closed\n (> 65 %)'),
10: (205, 181, 75, 255, 'Cultivated Terrestrial\n Vegetated: Woody Open\n (40 to 65 %)'),
11: (213, 193, 79, 255, 'Cultivated Terrestrial\n Vegetated: Woody Open\n (15 to 40 %)'),
12: (228, 210, 108, 255, 'Cultivated Terrestrial\n Vegetated: Woody Sparse\n (4 to 15 %)'),
13: (242, 227, 138, 255, 'Cultivated Terrestrial\n Vegetated: Woody Scattered\n (1 to 4 %)'),
14: (228, 224, 52, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous Closed\n (> 65 %)'),
15: (235, 232, 84, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous Open\n (40 to 65 %)'),
16: (242, 240, 127, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous Open\n (15 to 40 %)'),
17: (249, 247, 174, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous Sparse\n (4 to 15 %)'),
18: (255, 254, 222, 255, 'Cultivated Terrestrial\n Vegetated: Herbaceous Scattered\n (1 to 4 %)'),
19: (14, 121, 18, 255, 'Natural Terrestrial Vegetated:'),
20: (26, 177, 87, 255, 'Natural Terrestrial Vegetated: Woody'),
21: (94, 179, 31, 255, 'Natural Terrestrial Vegetated: Herbaceous'),
22: (14, 121, 18, 255, 'Natural Terrestrial Vegetated: Closed (> 65 %)'),
23: (45, 141, 47, 255, 'Natural Terrestrial Vegetated: Open (40 to 65 %)'),
24: (80, 160, 82, 255, 'Natural Terrestrial Vegetated: Open (15 to 40 %)'),
25: (117, 180, 118, 255, 'Natural Terrestrial Vegetated: Sparse (4 to 15 %)'),
26: (154, 199, 156, 255, 'Natural Terrestrial Vegetated: Scattered (1 to 4 %)'),
27: (14, 121, 18, 255, 'Natural Terrestrial Vegetated: Woody Closed (> 65 %)'),
28: (45, 141, 47, 255, 'Natural Terrestrial Vegetated: Woody Open (40 to 65 %)'),
29: (80, 160, 82, 255, 'Natural Terrestrial Vegetated: Woody Open (15 to 40 %)'),
30: (117, 180, 118, 255, 'Natural Terrestrial Vegetated: Woody Sparse (4 to 15 %)'),
31: (154, 199, 156, 255, 'Natural Terrestrial Vegetated: Woody Scattered (1 to 4 %)'),
32: (119, 167, 30, 255, 'Natural Terrestrial Vegetated: Herbaceous Closed (> 65 %)'),
33: (136, 182, 51, 255, 'Natural Terrestrial Vegetated: Herbaceous Open (40 to 65 %)'),
34: (153, 196, 80, 255, 'Natural Terrestrial Vegetated: Herbaceous Open (15 to 40 %)'),
35: (170, 212, 113, 255, 'Natural Terrestrial Vegetated: Herbaceous Sparse (4 to 15 %)'),
36: (186, 226, 146, 255, 'Natural Terrestrial Vegetated: Herbaceous Scattered (1 to 4 %)'),
37: (86, 236, 231, 255, 'Cultivated Aquatic Vegetated:'),
38: (61, 170, 140, 255, 'Cultivated Aquatic Vegetated: Woody'),
39: (82, 231, 172, 255, 'Cultivated Aquatic Vegetated: Herbaceous'),
40: (43, 210, 203, 255, 'Cultivated Aquatic Vegetated: Closed (> 65 %)'),
41: (73, 222, 216, 255, 'Cultivated Aquatic Vegetated: Open (40 to 65 %)'),
42: (110, 233, 228, 255, 'Cultivated Aquatic Vegetated: Open (15 to 40 %)'),
43: (149, 244, 240, 255, 'Cultivated Aquatic Vegetated: Sparse (4 to 15 %)'),
44: (187, 255, 252, 255, 'Cultivated Aquatic Vegetated: Scattered (1 to 4 %)'),
# 45: (43, 210, 203, 255, 'Cultivated Aquatic Vegetated: Woody Closed (> 65 %)'),
# 46: (73, 222, 216, 255, 'Cultivated Aquatic Vegetated: Woody Open (40 to 65 %)'),
# 47: (110, 233, 228, 255, 'Cultivated Aquatic Vegetated: Woody Open (15 to 40 %)'),
# 48: (149, 244, 240, 255, 'Cultivated Aquatic Vegetated: Woody Sparse (4 to 15 %)'),
# 49: (187, 255, 252, 255, 'Cultivated Aquatic Vegetated: Woody Scattered (1 to 4 %)'),
50: (82, 231, 196, 255, 'Cultivated Aquatic Vegetated: Herbaceous Closed (> 65 %)'),
51: (113, 237, 208, 255, 'Cultivated Aquatic Vegetated: Herbaceous Open (40 to 65 %)'),
52: (144, 243, 220, 255, 'Cultivated Aquatic Vegetated: Herbaceous Open (15 to 40 %)'),
53: (175, 249, 232, 255, 'Cultivated Aquatic Vegetated: Herbaceous Sparse (4 to 15 %)'),
54: (207, 255, 244, 255, 'Cultivated Aquatic Vegetated: Herbaceous Scattered (1 to 4 %)'),
55: (30, 191, 121, 255, 'Natural Aquatic Vegetated:'),
56: (18, 142, 148, 255, 'Natural Aquatic Vegetated: Woody'),
57: (112, 234, 134, 255, 'Natural Aquatic Vegetated: Herbaceous'),
58: (25, 173, 109, 255, 'Natural Aquatic Vegetated: Closed (> 65 %)'),
59: (53, 184, 132, 255, 'Natural Aquatic Vegetated: Open (40 to 65 %)'),
60: (93, 195, 155, 255, 'Natural Aquatic Vegetated: Open (15 to 40 %)'),
61: (135, 206, 178, 255, 'Natural Aquatic Vegetated: Sparse (4 to 15 %)'),
62: (176, 218, 201, 255, 'Natural Aquatic Vegetated: Scattered (1 to 4 %)'),
63: (25, 173, 109, 255, 'Natural Aquatic Vegetated: Woody Closed (> 65 %)'),
64: (25, 173, 109, 255, 'Natural Aquatic Vegetated: Woody Closed (> 65 %) Water > 3 months (semi-) permenant'),
65: (25, 173, 109, 255, 'Natural Aquatic Vegetated: Woody Closed (> 65 %) Water < 3 months (temporary or seasonal)'),
66: (53, 184, 132, 255, 'Natural Aquatic Vegetated: Woody Open (40 to 65 %)'),
67: (53, 184, 132, 255, 'Natural Aquatic Vegetated: Woody Open (40 to 65 %) Water > 3 months (semi-) permenant'),
68: (53, 184, 132, 255, 'Natural Aquatic Vegetated: Woody Open (40 to 65 %) Water < 3 months (temporary or seasonal)'),
69: (93, 195, 155, 255, 'Natural Aquatic Vegetated: Woody Open (15 to 40 %)'),
70: (93, 195, 155, 255, 'Natural Aquatic Vegetated: Woody Open (15 to 40 %) Water > 3 months (semi-) permenant'),
71: (93, 195, 155, 255, 'Natural Aquatic Vegetated: Woody Open (15 to 40 %) Water < 3 months (temporary or seasonal)'),
72: (135, 206, 178, 255, 'Natural Aquatic Vegetated: Woody Sparse (4 to 15 %)'),
73: (135, 206, 178, 255, 'Natural Aquatic Vegetated: Woody Sparse (4 to 15 %) Water > 3 months (semi-) permenant'),
74: (135, 206, 178, 255, 'Natural Aquatic Vegetated: Woody Sparse (4 to 15 %) Water < 3 months (temporary or seasonal)'),
75: (176, 218, 201, 255, 'Natural Aquatic Vegetated: Woody Scattered (1 to 4 %)'),
76: (176, 218, 201, 255, 'Natural Aquatic Vegetated: Woody Scattered (1 to 4 %) Water > 3 months (semi-) permenant'),
77: (176, 218, 201, 255, 'Natural Aquatic Vegetated: Woody Scattered (1 to 4 %) Water < 3 months (temporary or seasonal)'),
78: (39, 204, 139, 255, 'Natural Aquatic Vegetated: Herbaceous Closed (> 65 %)'),
79: (39, 204, 139, 255, 'Natural Aquatic Vegetated: Herbaceous Closed (> 65 %) Water > 3 months (semi-) permenant'),
80: (39, 204, 139, 255, 'Natural Aquatic Vegetated: Herbaceous Closed (> 65 %) Water < 3 months (temporary or seasonal)'),
81: (66, 216, 159, 255, 'Natural Aquatic Vegetated: Herbaceous Open (40 to 65 %)'),
82: (66, 216, 159, 255, 'Natural Aquatic Vegetated: Herbaceous Open (40 to 65 %) Water > 3 months (semi-) permenant'),
83: (66, 216, 159, 255, 'Natural Aquatic Vegetated: Herbaceous Open (40 to 65 %) Water < 3 months (temporary or seasonal)'),
84: (99, 227, 180, 255, 'Natural Aquatic Vegetated: Herbaceous Open (15 to 40 %)'),
85: (99, 227, 180, 255, 'Natural Aquatic Vegetated: Herbaceous Open (15 to 40 %) Water > 3 months (semi-) permenant'),
86: (99, 227, 180, 255, 'Natural Aquatic Vegetated: Herbaceous Open (15 to 40 %) Water < 3 months (temporary or seasonal)'),
87: (135, 239, 201, 255, 'Natural Aquatic Vegetated: Herbaceous Sparse (4 to 15 %)'),
88: (135, 239, 201, 255, 'Natural Aquatic Vegetated: Herbaceous Sparse (4 to 15 %) Water > 3 months (semi-) permenant'),
89: (135, 239, 201, 255, 'Natural Aquatic Vegetated: Herbaceous Sparse (4 to 15 %) Water < 3 months (temporary or seasonal)'),
90: (171, 250, 221, 255, 'Natural Aquatic Vegetated: Herbaceous Scattered (1 to 4 %)'),
91: (171, 250, 221, 255, 'Natural Aquatic Vegetated: Herbaceous Scattered (1 to 4 %) Water > 3 months (semi-) permenant'),
92: (171, 250, 221, 255, 'Natural Aquatic Vegetated: Herbaceous Scattered (1 to 4 %) Water < 3 months (temporary or seasonal)'),
93: (218, 92, 105, 255, 'Artificial Surface:'),
94: (243, 171, 105, 255, 'Natural Surface:'),
95: (255, 230, 140, 255, 'Natural Surface: Sparsely vegetated'),
96: (250, 210, 110, 255, 'Natural Surface: Very sparsely vegetated'),
97: (243, 171, 105, 255, 'Natural Surface: Bare areas, unvegetated'),
98: (77, 159, 220, 255, 'Water:'),
99: (77, 159, 220, 255, 'Water: (Water)'),
100: (187, 220, 233, 255, 'Water: (Water) Tidal area'),
101: (27, 85, 186, 255, 'Water: (Water) Perennial (> 9 months)'),
102: (52, 121, 201, 255, 'Water: (Water) Non-perennial (7 to 9 months)'),
103: (79, 157, 217, 255, 'Water: (Water) Non-perennial (4 to 6 months)'),
104: (133, 202, 253, 255, 'Water: (Water) Non-perennial (1 to 3 months)'),
# 105: (250, 250, 250, 255, 'Water: (Snow)')
255: (255, 255, 255, 255, "No Data")
},
'level4_colourbar_labels': {9: (197, 168, 71, 255, 'Cultivated Terrestrial Vegetated: Woody Closed (> 65 %)'),
10: (205, 181, 75, 255, 'Cultivated Terrestrial Vegetated: Woody Open (40 to 65 %)'),
11: (213, 193, 79, 255, 'Cultivated Terrestrial Vegetated: Woody Open (15 to 40 %)'),
12: (228, 210, 108, 255, 'Cultivated Terrestrial Vegetated: Woody Sparse (4 to 15 %)'),
13: (242, 227, 138, 255, 'Cultivated Terrestrial Vegetated: Woody Scattered (1 to 4 %)'),
14: (228, 224, 52, 255, 'Cultivated Terrestrial Vegetated: Herbaceous Closed (> 65 %)'),
15: (235, 232, 84, 255, 'Cultivated Terrestrial Vegetated: Herbaceous Open (40 to 65 %)'),
16: (242, 240, 127, 255, 'Cultivated Terrestrial Vegetated: Herbaceous Open (15 to 40 %)'),
17: (249, 247, 174, 255, 'Cultivated Terrestrial Vegetated: Herbaceous Sparse (4 to 15 %)'),
18: (255, 254, 222, 255, 'Cultivated Terrestrial Vegetated: Herbaceous Scattered (1 to 4 %)'),
27: (14, 121, 18, 255, 'Natural Terrestrial Vegetated: Woody Closed (> 65 %)'),
28: (45, 141, 47, 255, 'Natural Terrestrial Vegetated: Woody Open (40 to 65 %)'),
29: (80, 160, 82, 255, 'Natural Terrestrial Vegetated: Woody Open (15 to 40 %)'),
30: (117, 180, 118, 255, 'Natural Terrestrial Vegetated: Woody Sparse (4 to 15 %)'),
31: (154, 199, 156, 255, 'Natural Terrestrial Vegetated: Woody Scattered (1 to 4 %)'),
32: (119, 167, 30, 255, 'Natural Terrestrial Vegetated: Herbaceous Closed (> 65 %)'),
33: (136, 182, 51, 255, 'Natural Terrestrial Vegetated: Herbaceous Open (40 to 65 %)'),
34: (153, 196, 80, 255, 'Natural Terrestrial Vegetated: Herbaceous Open (15 to 40 %)'),
35: (170, 212, 113, 255, 'Natural Terrestrial Vegetated: Herbaceous Sparse (4 to 15 %)'),
36: (186, 226, 146, 255, 'Natural Terrestrial Vegetated: Herbaceous Scattered (1 to 4 %)'),
65: (25, 173, 109, 255, 'Natural Aquatic Vegetated: Woody Closed (> 65 %)'),
68: (53, 184, 132, 255, 'Natural Aquatic Vegetated: Woody Open (40 to 65 %)'),
71: (93, 195, 155, 255, 'Natural Aquatic Vegetated: Woody Open (15 to 40 %)'),
74: (135, 206, 178, 255, 'Natural Aquatic Vegetated: Woody Sparse (4 to 15 %)'),
77: (176, 218, 201, 255, 'Natural Aquatic Vegetated: Woody Scattered (1 to 4 %)'),
80: (39, 204, 139, 255, 'Natural Aquatic Vegetated: Herbaceous Closed (> 65 %)'),
83: (66, 216, 159, 255, 'Natural Aquatic Vegetated: Herbaceous Open (40 to 65 %)'),
86: (99, 227, 180, 255, 'Natural Aquatic Vegetated: Herbaceous Open (15 to 40 %)'),
89: (135, 239, 201, 255, 'Natural Aquatic Vegetated: Herbaceous Sparse (4 to 15 %)'),
92: (171, 250, 221, 255, 'Natural Aquatic Vegetated: Herbaceous Scattered (1 to 4 %)'),
93: (218, 92, 105, 255, 'Artificial Surface'),
95: (255, 230, 140, 255, 'Natural Surface: Sparsely vegetated'),
96: (250, 210, 110, 255, 'Natural Surface: Very sparsely vegetated'),
97: (243, 171, 105, 255, 'Natural Surface: Bare areas, unvegetated'),
100: (187, 220, 233, 255, 'Water: (Water) Tidal area'),
101: (27, 85, 186, 255, 'Water: (Water) Perennial (> 9 months)'),
102: (52, 121, 201, 255, 'Water: (Water) Non-perennial (7 to 9 months)'),
103: (79, 157, 217, 255, 'Water: (Water) Non-perennial (4 to 6 months)'),
104: (133, 202, 253, 255, 'Water: (Water) Non-perennial (1 to 3 months)'),
255: (255, 255, 255, 255, "No Data")
},
}
# dictionary needed to generate colour schemes of descriptors from the level 4 colour scheme. The structure is as follow:
# long_descriptor_name[string]: {keyword_for_finding_classes_in_level4_colourscheme[string] : (RGB_colourscheme[4 integers], label_of_descriptor[string])}
lc_colours_mapping = {
'lifeform_veg_cat_l4a': {'Woody': (14, 121,18, 255, 'Woody\nVegetation'),
'Herbaceous': (172, 188, 45, 255, 'Herbaceous\nVegetation')},
'canopyco_veg_cat_l4d': {'> 65 %': (14, 121, 18, 255, '> 65 %\ncover'),
'40 to 65 %': (45, 141, 47, 255, '40 to 65 %\ncover'),
'15 to 40 %': (80, 160, 82, 255, '15 to 40 %\ncover'),
'4 to 15 %': (117, 180, 118, 255, '4 to 15 %\ncover'),
'1 to 4 %': (154, 199, 156, 255, '1 to 4 %\ncover')},
'watersea_veg_cat_l4a_au': {'(semi-) permenant': (25, 173, 109, 255, '> 3 months'),
'(temporary or seasonal)': (176, 218, 201, 255, '< 3 months')},
'waterstt_wat_cat_l4a': {'Water: (Water)': (77, 159, 220, 255, 'Water')},
'inttidal_wat_cat_l4a': {'Tidal area': (77, 159, 220, 255, 'Tidal area')},
'waterper_wat_cat_l4d_au': {'> 9 months': (27, 85, 186, 255, '> 9\nmonths'),
'7 to 9 months': (52, 121, 201, 255, '7 to 9\nmonths'),
'4 to 6 months': (79, 157, 217, 255, '4 to 6\nmonths'),
'1 to 3 months': (113, 202, 253, 255, '1 to 3\nmonths')},
'baregrad_phy_cat_l4d_au': {'Sparsely vegetated': (255, 230, 140, 255, 'Sparsely\nvegetated\n(< 20% bare)'),
'Very sparesely': (250, 210, 110, 255, 'Very sparsely\nvegetated\n(20 to 60% bare)'),
'Bare areas': (243, 171, 105, 255, 'Bare areas,\nunvegetated\n(> 60% bare)')},
}
aliases = {
'lifeform': 'lifeform_veg_cat_l4a',
'vegetation_cover': 'canopyco_veg_cat_l4d',
'water_seasonality': 'watersea_veg_cat_l4a_au',
'water_state': 'waterstt_wat_cat_l4a',
'intertidal': 'inttidal_wat_cat_l4a',
'water_persistence': 'waterper_wat_cat_l4d_au',
'bare_gradation': 'baregrad_phy_cat_l4d_au',
'full_classification': 'level4',
'level_4': 'level4'
}
def _get_layer_name(measurement, da, aliases=aliases):
"""
Returns detailed name of descriptor given the short alias
"""
# Use provided measurement if able
measurement = measurement.lower() if measurement else da.name
measurement = aliases[measurement] if measurement in aliases.keys(
) else measurement
return measurement
def _descriptors_colours(lc_colours, lc_colours_mapping, descriptor):
"""
Generates a sorted dictionary of colours based on a given descriptor.
This function takes in a dictionary of Land Cover classes, a mapping of descriptors to colours,
and a specific descriptor. It returns a dictionary where the keys (i.e., classes values) are sorted
and the values are the corresponding colours and labels from the descriptor mapping.
Parameters
----------
lc_colours : dict
Dictionary containing colour schemes for all Land Cover classes,
including Level 4 scheme (needed in this function).
lc_colours_mapping : dict
Dictionary mapping descriptors (e.g., lifeform) to their corresponding colours and labels.
descriptor : str
The descriptor to be used for mapping colours.
Returns
-------
sorted_colours_dict : dict
Sorted dictionary with class values as keys and colour tuples as values.
"""
# get the level 4 colour scheme from the lc_colours dictionary
level4_colours = lc_colours['level4']
# get the descriptor dictionary from the lc_colours_mapping
descriptor_dict = lc_colours_mapping[descriptor]
# create a new colours dictionary with all level 4 values set to white colour
# this dictionary is the foundation of the output returned at the end
colours_dict = level4_colours.copy()
for key in colours_dict:
colours_dict[key] = (255, 255, 255, 255, "No Data/\nOther\nClasses")
# based on the descriptor, update the colours dictionary with the descriptor-specific colours
# (all the rest will stay white)
for class_keyword, colour_n_label in descriptor_dict.items(): # iterate over descriptors mapping keys
for class_value, lvl4_scheme in level4_colours.items(): # iterate over Level 4 colour scheme
# get the label of current Level 4 class colour
label_lvl4 = lvl4_scheme[4]
if class_keyword in label_lvl4: # check if the current Level 4 class colour contains the current descriptor mapping key
# replace white colour with RGB indicated by the descriptor mapping dictionary
colours_dict[class_value] = colour_n_label
# sort the colours dictionary by keys (i.e., the values of classes)
sorted_colours_dict = {key: colours_dict[key] for key in sorted(colours_dict.keys())}
return sorted_colours_dict
[docs]
def get_colour_scheme(measurement):
"""
Gets colour scheme dictionary given the measurement of interest
"""
# ensure a valid colour scheme was requested
assert (
(measurement in lc_colours.keys()) # either in main colour scheme dictionary
or (measurement in lc_colours_mapping.keys()) # or in mapping dictionary for descriptors
or (measurement in aliases.keys()) # or short aliases of descriptors
), f'colour scheme must be one of {lc_colours.keys()} {lc_colours_mapping.keys()} {aliases.keys()} (got "{measurement}")'
# if a descriptor colour scheme is required, use the _descriptors_colours function
if measurement in lc_colours_mapping:
colour_scheme=_descriptors_colours(lc_colours,lc_colours_mapping, measurement)
else: # else, use standard colours scheme
colour_scheme = lc_colours[measurement]
return colour_scheme
def _reduce_colour_scheme(colour_scheme):
"""
Takes a colour scheme dictionary and returns the dictionary without duplicate values.
This also replaces classes values with subsequent integers, useful for placing ticks of colourbar on the side
"""
# foundation of the output dictionary
reduced_scheme = {}
# empty list to be filled with names of classes added to the output dictionary
classes_added = []
new_key = 2 # key 1 was added earlier and corresponds with "no data"
for key, value in colour_scheme.items():
# get string with class name
class_name = value[4]
if (class_name not in classes_added): # check if already added in list
classes_added.append(class_name)
# assign the colour scheme and label to a new key in reduced_scheme
reduced_scheme[new_key] = value
# increase value of new_key for next iteration
new_key += 1
return reduced_scheme
[docs]
def lc_colourmap(colour_scheme):
"""
Takes a colour scheme dictionary and returns colormap for matplotlib
Returns
---------
cmap : matplotlib colormap
Matplotlib colormap containing the colour scheme for the
specified DEA Land Cover measurement.
norm : matplotlib colormap index
Matplotlib colormap index based on the discrete intervals of the
classes in the specified DEA Land Cover measurement. Ensures the
colormap maps the colours to the class numbers correctly.
"""
colour_arr = [] # empty list to be populated with colours
for key, value in colour_scheme.items():
colour_arr.append(np.array(value[:-2]) / 255) # add colour to list
# create a colour map from the list of colours
cmap = mcolours.ListedColormap(colour_arr)
# create boundaries of colours by using the exact class values and adding a larger value at the end
bounds = list(colour_scheme)
bounds.append(bounds[-1]+1)
# shift all boundaries back by 0.5 to make sure level4 values are within bounds
# this is a robust method to make sure each value is within a colour bin
bounds = [i-0.5 for i in bounds]
# normalisation for colourmap
norm = mcolours.BoundaryNorm(np.array(bounds), cmap.N)
return (cmap, norm)
def _legend_colourmap(colour_scheme):
"""
Returns colour map and normalisation specifcially for the colourbar
of the provided DEA Land Cover measurement, for use in plotting with Matplotlib library
Parameters
----------
colour_scheme : dictionary with colour scheme
Returns
---------
cb_cmap : matplotlib colormap
Matplotlib colormap containing the colour scheme for the
specified DEA Land Cover measurement.
cb_norm : matplotlib colormap index
Matplotlib colormap index based on the discrete intervals of the
classes in the specified DEA Land Cover measurement. Ensures the
colormap maps the colours to the class numbers correctly.
cb_labels : list
string labels of the classes found
in the chosen DEA Land Cover measurement.
cb_ticks : list
position of ticks in colour bar
"""
# delete duplicates to create colour bar (this effectively applies only with descriptors),
# and fix values for correct colourbar label positioning
colour_scheme = _reduce_colour_scheme(colour_scheme)
cb_cmap, cb_norm = lc_colourmap(colour_scheme)
cb_ticks = list(colour_scheme)
cb_labels = []
for x in cb_ticks:
cb_labels.append(colour_scheme[x][4])
return (cb_cmap, cb_norm, cb_labels, cb_ticks)
[docs]
def make_colourbar(fig, ax, measurement, labelsize=10, horizontal=False, animation=False): # in practice, horizontal arg in never used, currently
"""
Adds a new colorbar with appropriate land cover colours and labels.
For DEA Land Cover Level 4 data, this function must be used with a double plot.
The 'ax' should be on the left side of the figure, and the colour bar will added
on the right hand side.
Parameters
----------
fig : matplotlib figure
Figure to add colourbar to
ax : matplotlib ax
Matplotlib figure ax to add colorbar to.
measurement : string
name of layer or descriptor of interest
labelsize : int
size of labels of colourbar
Returns
----------
matplotlib colorbar in its own colour axis
"""
if measurement == 'level4':
colour_scheme = lc_colours['level4_colourbar_labels'] # use shorten labels dictionary
if animation == True:
# special spacing settings for level 4
cax = fig.add_axes([0.62, 0.05, 0.02, 0.90]) # parameters for add_axes are [left, bottom, width, height], in fractions of total plot
orient = 'vertical'
# get level 4 colour bar colour map ect
cb_cmap, cb_norm, cb_labels, cb_ticks = _legend_colourmap(colour_scheme)
elif animation == False:
#move plot over to make room for colourbar
fig.subplots_adjust(right=0.825)
# Settings for axis positions
cax = fig.add_axes([0.84, 0.145, 0.02, 0.70])
orient = 'vertical'
# get level 4 colour bar colour map ect
cb_cmap, cb_norm, cb_labels, cb_ticks = _legend_colourmap(colour_scheme)
else: #for all other measurements
colour_scheme = get_colour_scheme(measurement) # use standard colour scheme
#move plot over to make room for colourbar
fig.subplots_adjust(right=0.825)
# settings for different axis positions
if horizontal:
cax = fig.add_axes([0.02, 0.05, 0.90, 0.03])
orient = 'horizontal'
else:
cax = fig.add_axes([0.84, 0.145, 0.02, 0.70])
orient = 'vertical'
# get measurement colour bar colour map ect
cb_cmap, cb_norm, cb_labels, cb_ticks = _legend_colourmap(colour_scheme)
img = ax.imshow([cb_ticks], cmap=cb_cmap, norm=cb_norm)
cb = fig.colorbar(img, cax=cax, orientation=orient)
cb.ax.tick_params(labelsize=labelsize)
cb.set_ticks(cb_ticks)
cb.set_ticklabels(cb_labels)
[docs]
def plot_land_cover(data, labelsize=10, year=None, measurement=None, out_width=15, cols=4,):
"""
Plot a single land cover measurement with appropriate colour scheme.
Parameters
----------
data : xarray.DataArray
A dataArray containing a DEA Land Cover classification.
year : int, optional
Can be used to select to plot a specific year. If not provided,
all time slices are plotted.
measurement : string, optional
Name of the DEA land cover classification to be plotted. Passed to
_legend_colourmap to specify which colour scheme will be used. If non
provided, reads data array name from `da` to determine.
out_width : integer, optional
Specifies the desired width of the output plot in inches.
The height of the plot is scaled accordingly to maintain the
correct aspect ratio of the data
cols: integer, optional
Sets number of columns if multiple time steps are visualised
Returns
---------
Matplotlib image
"""
# get measurement name
measurement = _get_layer_name(measurement, data)
colour_scheme = get_colour_scheme(measurement)
cmap, norm = lc_colourmap(colour_scheme)
height, width = data.geobox.shape
scale = out_width / width
if year:
#plotting protocol if 'year' variable is passed
if int(year) not in pd.to_datetime(data.time.values).year: # check if year selecte is in the datacube
raise ValueError(f'Year {year} is not in the data array.')
year_string = f"{year}-07-01" # LC collection 3 dates are in July
data = data.sel(time=year_string, method='nearest')
fig, ax = plt.subplots()
fig.set_size_inches(width * scale, height * scale)
make_colourbar(fig, ax, measurement, labelsize)
im = ax.imshow(data.values, cmap=cmap, norm=norm, interpolation="nearest")
elif len(data.time) == 1:
#plotting protocol if only one timestep is passed and not a year variable
fig, ax = plt.subplots()
fig.set_size_inches(width * scale, height * scale)
make_colourbar(fig, ax, measurement, labelsize)
im = ax.imshow(data.isel(time=0), cmap=cmap, norm=norm, interpolation="nearest")
else:
#plotting protocol if multible time steps are passed to plot
if cols > len(data.time):
cols = len(data.time)
rows = int((len(data.time) + cols-1)/cols)
fig, ax = plt.subplots(nrows=rows, ncols=cols)
fig.set_size_inches(
width * scale, (height * scale / cols) * (len(data.time) / cols))
make_colourbar(fig, ax.flat[0], measurement, labelsize)
for a, b in enumerate(ax.flat):
if a < data.shape[0]:
im = b.imshow(data[a], cmap=cmap, norm=norm,
interpolation="nearest")
return im
def _calc_class_ratio(da, measurement):
"""
Creates a table listing year by year what percentage of the
total area is taken up by each class.
Parameters
----------
da : xarray.DataArray with time dimension
measurement: string with name of descriptor/measurement
Returns
-------
Pandas Dataframe : containing class percentages per year
"""
# list all class codes in dataset
list_classes = (np.unique(da, return_counts=False)).tolist()
# if a descriptor colour scheme is required, list_classes need to be chnaged to contain only classes of that descriptor
# the following code uses the _descriptors_colours function to get the colours scheme and then the values of the descriptor of interest
if measurement in lc_colours_mapping:
lc_colour_scheme=_descriptors_colours(lc_colours,lc_colours_mapping, measurement)
# sort based on RGB colour, so stack plot will show same colours next to each other
lc_colour_scheme= dict(sorted(lc_colour_scheme.items(), key=lambda item: item[1][0:3]))
# create list of values
all_classes_descriptor = list(lc_colour_scheme.keys())
# out of all possible classes of that descriptor, keep only the ones actually in the data array
list_classes = [i for i in all_classes_descriptor if i in list_classes] # the order of all_classes_descriptor and list_classes is important: the correct sorting order is the one of all_classes_descriptor
# create empty dataframe & dictionary
ratio_table = pd.DataFrame(data=None, columns=list_classes)
date_line = {}
# count all pixels, should be consistent
total_pix = da.isel(time=1).size
# iterate through each year in dataset
for i in range(0, len(da.time)):
date = str(da.time[i].data)[0:10]
# for each year iterate though each present class number
# and count pixels
for n in list_classes:
number_of_pixles = int(np.sum(da.isel(time=i) == n))
percentage = number_of_pixles / total_pix * 100
date_line[n] = percentage
# add each year's counts to dataframe
ratio_table.loc[date] = date_line
return ratio_table
[docs]
def lc_animation(
da,
file_name="default_animation",
measurement=None,
stacked_plot=False,
colour_bar=False,
animation_interval=500,
out_width=20,
dpi=150,
font_size=15,
label_size = 15,
label_ax=True,
):
"""
Creates an animation of DEA Landcover though time beside
corresponding stacked plots of the landcover classes. Saves the
animation to a file and displays the animation in notebook.
Parameters
----------
da : xarray.DataArray
An xarray.DataArray containing a multi-date stack of
observations of a single landcover level.
file_name: string, optional.
string used to create filename for saved animation file.
Default: "default_animation" code adds .gif suffix.
measurement : string, optional
Name of the DEA land cover classification to be plotted. Passed to
_legend_colourmap to specify which colour scheme will be used. If non
provided, reads data array name from `da` to determine.
stacked_plot: boolean, optional
Determines if a stacked plot showing the percentage of area
taken up by each class in each time slice is added to the
animation. Default: False.
colour_bar : boolean, Optional
Determines if a colour bar is generated for the stand alone
animation. This is NOT recommended for use with level 4 data.
Does not work with stacked plot. Default: False.
animation_interval : int , optional
How quickly the frames of the animations should be re-drawn.
Default: 500.
out_width : integer, optional
Specifies the desired width of the output plot in inches.
The height of the plot is scaled accordingly to maintain the
correct aspect ratio of the data
dpi : int, optional
Stands for 'Dots Per Inch'. Passed to the fuction that saves the
animation and determines the resolution. A higher number will
produce a higher resolution image but a larger file size and
slower processing. Default: 150.
font_size : int, optional.
Controls the size of the text on the axes and colour bar. Default: 15.
label_size : int, optional.
Controls the size of the text which indicates the year
displayed. Default: 15.
label_ax : boolean, optional
Determines if animation plot should have tick marks and numbers
on axes. Also removes white space around plot. default: True
Returns
-------
A GIF (.gif) animation file.
"""
# Add gif to end of filename
file_name = file_name + ".gif"
# get long name of measurement/variable
measurement = _get_layer_name(measurement, da)
# get colour scheme
colour_scheme = get_colour_scheme(measurement)
# Create colour map and normalisation for specified lc measurement
layer_cmap, layer_norm = lc_colourmap(colour_scheme)
# Get info on dataset dimensions and define size of output
height, width = da.geobox.shape
scale = out_width / width
left, bottom, right, top = da.geobox.extent.boundingbox
extent = [left, right, bottom, top]
# settings for the label showed on top of the images
annotation_defaults = {
"xy": (1, 1),
"xycoords": "axes fraction",
"xytext": (-5, -5),
"textcoords": "offset points",
"horizontalalignment": "right",
"verticalalignment": "top",
"fontsize": label_size,
"color": "white",
"path_effects": [patheffects.withStroke(linewidth=1,
foreground="black")],
}
# Get information needed to display the year in the top corner
times_list = da.time.dt.strftime("%Y").values
text_list = [False] * len(times_list)
annotation_list = ["\n".join([str(i) for i in (a, b) if i])
for a, b in zip(times_list, text_list)]
if stacked_plot == True: # if need to add stacked line plot on the right
# Create table for stacked plot
stacked_plot_table = _calc_class_ratio(da, measurement)
# Build colour list of hex vals for stacked plot
def _rgb_to_hex(r, g, b):
hex = "#%x%x%x" % (r, g, b)
if len(hex) < 7:
hex = "#0" + hex[1:]
return hex
hex_colour_list = []
for val in list(stacked_plot_table):
r, g, b = colour_scheme[val][0:3]
hex_val = _rgb_to_hex(r, g, b)
hex_colour_list.append(hex_val)
# Define & set up figure (two axes: the LC array and the stacked line plot)
fig, (ax1, ax2) = plt.subplots(1, 2, dpi=dpi, constrained_layout=True)
fig.set_size_inches(width * scale, height * scale / 2, forward=True)
fig.set_constrained_layout_pads(w_pad=0.2, h_pad=0.2, hspace=0, wspace=0)
# set the size of the ticks labels using font_size
ax1.tick_params(axis='both', which='major', labelsize=font_size)
ax2.tick_params(axis='both', which='major', labelsize=font_size)
# define list of axes to use in anim_fargs and, in turn, in _update_frames
axes = [ax1,ax2]
else: # i.e., stacked_plot == False
# if plotting level 4 with colourbar
if measurement == 'level4' and colour_bar == True:
# specific setting to fit level 4 colour bar beside the plot
# we will plot the animation in the left hand plot
# and put the colour bar on the right hand side
# Define & set up figure, two subplots so colour bar fits :)
fig, (ax1, ax2) = plt.subplots(1, 2, dpi=dpi,
constrained_layout=True, gridspec_kw={'width_ratios': [3, 1]})
fig.set_size_inches(width * scale, height * scale / 2, forward=True)
fig.set_constrained_layout_pads(w_pad=0.2, h_pad=0.2, hspace=0, wspace=0)
# make colour bar
# provide left hand canvas to colour bar fuction which is where the image will go
# colourbar will plot on right side beside it
make_colourbar(fig, ax1, measurement, labelsize=font_size, animation=True)
# turn off lines for second plot so it's not ontop of colourbar
ax2.set_axis_off()
# plotting any other measurement with or with-out colour bar or level 4 without
else:
# Define & set up figure
fig, ax1 = plt.subplots(1, 1, dpi=dpi)
fig.set_size_inches(width * scale, height * scale, forward=True)
if(not label_ax):
fig.subplots_adjust(left=0, bottom=0, right=1,
top=1, wspace=None, hspace=None)
# make colourbar if required
if colour_bar:
make_colourbar(fig, ax1, measurement, labelsize=font_size)
# set the size of the ticks labels using font_size
ax1.tick_params(axis='both', which='major', labelsize=font_size)
# define list of axes to use in anim_fargs and, in turn, in _update_frames
axes = [ax1]
#################################################################
#### This function is called at the end at regular intervals ####
#### with changing i values for each frame ####
#################################################################
def _update_frames(i, axes, extent, annotation_text,
annotation_defaults, cmap, norm):
ax1 = axes[0] # at least one axis is always present
# Clear previous frame to optimise render speed and plot imagery
ax1.clear()
# Add annotation text
ax1.annotate(annotation_text[i], **annotation_defaults)
# Generate image
ax1.imshow(da[i, ...], cmap=cmap, norm=norm,
extent=extent, interpolation="nearest")
# set size of 1e6 using font_size
ax1.yaxis.get_offset_text().set_fontsize(font_size)
ax1.xaxis.get_offset_text().set_fontsize(font_size)
# if asked that axes have no labels, remove them
if(not label_ax):
ax1.set_axis_off()
try: # this will fail and be skipped if a second axes (i.e. stacked line plot) does not exist
ax2 = axes[1]
ax2.clear()
# get the classes ratio up to the current time step i
clipped_table = stacked_plot_table.iloc[: int(i + 1)]
data = clipped_table.to_dict(orient="list")
date = clipped_table.index
# add stacked line plot to axes 2
ax2.stackplot(date, data.values(), colors=hex_colour_list)
ax2.tick_params(axis="x", labelrotation=-90)
ax2.margins(x=0, y=0)
# Add annotation text
ax2.annotate(annotation_text[i], **annotation_defaults)
# set size of 1e6 using font_size
ax2.yaxis.get_offset_text().set_fontsize(font_size)
ax2.xaxis.get_offset_text().set_fontsize(font_size)
except:
pass
#################################################################
#################################################################
# anim_fargs contains all the values we send to our
# _update_frames function.
anim_fargs = (
axes,
[left, right, bottom, top], # imshow extent
annotation_list,
annotation_defaults,
layer_cmap,
layer_norm,
)
# create animation
anim = FuncAnimation(
fig=fig,
func=_update_frames,
fargs=anim_fargs,
frames=len(da.time),
interval=animation_interval,
repeat=False,
)
# save animation
anim.save(file_name, writer="pillow", dpi=dpi)
plt.close()
return Image(filename=file_name)