wildfire-analyser 0.1.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wildfire-analyser might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Marcelo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: wildfire-analyser
3
+ Version: 0.1.10
4
+ Summary: Python library for post-fire assessment and wildfire analysis using Google Earth Engine.
5
+ Author: Marcelo Camargo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/camargo-advanced/wildfire-analyser
8
+ Project-URL: Source, https://github.com/camargo-advanced/wildfire-analyser
9
+ Project-URL: Issues, https://github.com/camargo-advanced/wildfire-analyser/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: geemap==0.36.6
14
+ Requires-Dist: geopandas==1.1.1
15
+ Requires-Dist: geedim==2.0.0
16
+ Requires-Dist: python-dotenv==1.0.1
17
+ Dynamic: license-file
18
+
19
+ # wildfire-analyser
20
+
21
+ Python project for analyzing wildfires in natural reserves.
22
+
23
+ ## Installation and Usage
24
+
25
+ Follow the steps below to install and test `wildfire-analyser` inside an isolated environment:
26
+
27
+ ```bash
28
+ mkdir /tmp/test
29
+ cd /tmp/test
30
+
31
+ python3 -m venv venv
32
+ source venv/bin/activate
33
+
34
+ pip install wildfire-analyser
35
+ ```
36
+
37
+ ### Required Files Before Running the Client
38
+
39
+ Before running the client, you **must** prepare two items:
40
+
41
+ ---
42
+
43
+ #### **1. Add a GeoJSON polygon**
44
+
45
+ Create a folder named `polygons` in the project root and place your ROI polygon file inside it:
46
+
47
+ ```
48
+ /tmp/test/
49
+ ├── polygons/
50
+ │ └── your_polygon.geojson
51
+ └── venv/
52
+ ```
53
+
54
+ An example GeoJSON file is available in the repository.
55
+
56
+ ---
57
+
58
+ #### **2. Create the `.env` file with GEE authentication data**
59
+
60
+ In the project root, add a `.env` file containing your Google Earth Engine authentication variables.
61
+
62
+ A `.env` template is also available in the GitHub repository.
63
+
64
+ ```
65
+ /tmp/test/
66
+ ├── .env
67
+ ├── polygons/
68
+ └── venv/
69
+ ```
70
+
71
+ ---
72
+
73
+ ### Running the Client
74
+
75
+ After adding the `.env` file and your GeoJSON polygon:
76
+
77
+ ```bash
78
+ python3 -m wildfire_analyser.client
79
+ ```
80
+
81
+ This will start the analysis process, generate the configured deliverables, and save the output files in the current directory.
82
+
83
+
84
+
85
+ ## Setup Instructions for Developers
86
+
87
+ 1. **Clone the repository**
88
+
89
+ ```bash
90
+ git clone git@github.com:camargo-advanced/wildfire-analyser.git
91
+ cd wildfire-analyser
92
+ ```
93
+
94
+ 2. **Create a virtual environment**
95
+
96
+ ```bash
97
+ python3 -m venv venv
98
+ ```
99
+
100
+ 3. **Activate the virtual environment**
101
+
102
+ ```bash
103
+ source venv/bin/activate
104
+ ```
105
+
106
+ 4. **Install dependencies**
107
+ Make sure the virtual environment is activated, then run:
108
+
109
+ ```bash
110
+ pip install -r requirements.txt
111
+ ```
112
+
113
+ 5. **Configure environment variables**
114
+ Copy your version of `.env` file to the root folder with your GEE authentication credentials. A template file `.env.template` is provided as an example.
115
+
116
+ 6. **Run the sample client application**
117
+
118
+ ```bash
119
+ python3 -m wildfire_analyser.client
120
+ ```
121
+
122
+ ## Useful Commands
123
+
124
+ * **Deactivate the virtual environment**:
125
+
126
+ ```bash
127
+ deactivate
128
+ ```
@@ -0,0 +1,110 @@
1
+ # wildfire-analyser
2
+
3
+ Python project for analyzing wildfires in natural reserves.
4
+
5
+ ## Installation and Usage
6
+
7
+ Follow the steps below to install and test `wildfire-analyser` inside an isolated environment:
8
+
9
+ ```bash
10
+ mkdir /tmp/test
11
+ cd /tmp/test
12
+
13
+ python3 -m venv venv
14
+ source venv/bin/activate
15
+
16
+ pip install wildfire-analyser
17
+ ```
18
+
19
+ ### Required Files Before Running the Client
20
+
21
+ Before running the client, you **must** prepare two items:
22
+
23
+ ---
24
+
25
+ #### **1. Add a GeoJSON polygon**
26
+
27
+ Create a folder named `polygons` in the project root and place your ROI polygon file inside it:
28
+
29
+ ```
30
+ /tmp/test/
31
+ ├── polygons/
32
+ │ └── your_polygon.geojson
33
+ └── venv/
34
+ ```
35
+
36
+ An example GeoJSON file is available in the repository.
37
+
38
+ ---
39
+
40
+ #### **2. Create the `.env` file with GEE authentication data**
41
+
42
+ In the project root, add a `.env` file containing your Google Earth Engine authentication variables.
43
+
44
+ A `.env` template is also available in the GitHub repository.
45
+
46
+ ```
47
+ /tmp/test/
48
+ ├── .env
49
+ ├── polygons/
50
+ └── venv/
51
+ ```
52
+
53
+ ---
54
+
55
+ ### Running the Client
56
+
57
+ After adding the `.env` file and your GeoJSON polygon:
58
+
59
+ ```bash
60
+ python3 -m wildfire_analyser.client
61
+ ```
62
+
63
+ This will start the analysis process, generate the configured deliverables, and save the output files in the current directory.
64
+
65
+
66
+
67
+ ## Setup Instructions for Developers
68
+
69
+ 1. **Clone the repository**
70
+
71
+ ```bash
72
+ git clone git@github.com:camargo-advanced/wildfire-analyser.git
73
+ cd wildfire-analyser
74
+ ```
75
+
76
+ 2. **Create a virtual environment**
77
+
78
+ ```bash
79
+ python3 -m venv venv
80
+ ```
81
+
82
+ 3. **Activate the virtual environment**
83
+
84
+ ```bash
85
+ source venv/bin/activate
86
+ ```
87
+
88
+ 4. **Install dependencies**
89
+ Make sure the virtual environment is activated, then run:
90
+
91
+ ```bash
92
+ pip install -r requirements.txt
93
+ ```
94
+
95
+ 5. **Configure environment variables**
96
+ Copy your version of `.env` file to the root folder with your GEE authentication credentials. A template file `.env.template` is provided as an example.
97
+
98
+ 6. **Run the sample client application**
99
+
100
+ ```bash
101
+ python3 -m wildfire_analyser.client
102
+ ```
103
+
104
+ ## Useful Commands
105
+
106
+ * **Deactivate the virtual environment**:
107
+
108
+ ```bash
109
+ deactivate
110
+ ```
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "wildfire-analyser"
3
+ version = "0.1.10"
4
+ description = "Python library for post-fire assessment and wildfire analysis using Google Earth Engine."
5
+ authors = [
6
+ { name = "Marcelo Camargo" }
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+
12
+ dependencies = [
13
+ "geemap==0.36.6",
14
+ "geopandas==1.1.1",
15
+ "geedim==2.0.0",
16
+ "python-dotenv==1.0.1",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/camargo-advanced/wildfire-analyser"
21
+ Source = "https://github.com/camargo-advanced/wildfire-analyser"
22
+ Issues = "https://github.com/camargo-advanced/wildfire-analyser/issues"
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["wildfire_analyser*"]
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=61.0", "wheel"]
29
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .fire_assessment.post_fire_assessment import PostFireAssessment
2
+ from .fire_assessment.deliverable import Deliverable
3
+ from .fire_assessment.fire_severity import FireSeverity
4
+ __all__ = ["PostFireAssessment", "Deliverable", "FireSeverity"]
@@ -0,0 +1,72 @@
1
+ # client.py
2
+ import logging
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ from wildfire_analyser import PostFireAssessment, Deliverable, FireSeverity
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def main():
12
+ # Configure global logging format and level
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format="%(asctime)s [%(levelname)s] %(message)s"
16
+ )
17
+ logger.info("Client starts")
18
+
19
+ try:
20
+ # Load the local .env file
21
+ load_dotenv()
22
+
23
+ # Read the environment variable from .env
24
+ gee_key_json = os.getenv("GEE_PRIVATE_KEY_JSON")
25
+ if gee_key_json is None:
26
+ raise ValueError("GEE_PRIVATE_KEY_JSON environment variable is not set in .env")
27
+
28
+ # Path to the GeoJSON polygon used as the Region of Interest (ROI)
29
+ geojson_path = os.path.join("polygons", "APAPiracicabaJuqueriMirimAreaII.geojson")
30
+ #geojson_path = os.path.join("polygons", "eejatai.geojson")
31
+
32
+ # Initialize the wildfire assessment processor with date range
33
+ runner = PostFireAssessment(gee_key_json,
34
+ geojson_path, "2024-09-01", "2024-11-08",
35
+ deliverables=[
36
+ Deliverable.RGB_PRE_FIRE,
37
+ Deliverable.RGB_POST_FIRE,
38
+ #Deliverable.NDVI_PRE_FIRE,
39
+ #Deliverable.NDVI_POST_FIRE,
40
+ #Deliverable.RBR,
41
+ ],
42
+ track_timings=True)
43
+
44
+ # Run the analysis
45
+ result = runner.run_analysis()
46
+
47
+ # Print fire severity
48
+ for row in result["area_by_severity"]:
49
+ logger.info(
50
+ f"{row['severity_name']}({row['severity']}): {row['ha']:.2f} ha ({row['percent']:.2f}%) -> {row['color']}"
51
+ )
52
+
53
+ # Save each deliverable to local files
54
+ for key, item in result["images"].items():
55
+ with open(item["filename"], "wb") as f:
56
+ f.write(item["data"])
57
+ logger.info(f"Saved file: {item['filename']}")
58
+
59
+ # Print processing time metrics
60
+ timings = result.get("timings", {})
61
+ logger.info("Stats:")
62
+ for key, value in timings.items():
63
+ logger.info(f" → {key}: {value:.2f} sec")
64
+
65
+ logger.info("Client ends")
66
+
67
+ except Exception as e:
68
+ logger.exception("Unexpected error during processing")
69
+
70
+ # Entry point
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,14 @@
1
+ # date_utils.py
2
+ import logging
3
+ from datetime import datetime, timedelta
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def expand_dates(start_date: str, end_date: str, days_before_after: int):
9
+ sd = datetime.strptime(start_date, "%Y-%m-%d")
10
+ ed = datetime.strptime(end_date, "%Y-%m-%d")
11
+ before_start = (sd - timedelta(days=days_before_after)).strftime("%Y-%m-%d")
12
+ after_end = (ed + timedelta(days=days_before_after)).strftime("%Y-%m-%d")
13
+ return before_start, start_date, end_date, after_end
14
+
@@ -0,0 +1,12 @@
1
+ # deliverable.py
2
+ from enum import Enum
3
+
4
+ class Deliverable(Enum):
5
+ RGB_PRE_FIRE = "rgb_pre_fire"
6
+ RGB_POST_FIRE = "rgb_post_fire"
7
+ NDVI_PRE_FIRE = "ndvi_pre_fire"
8
+ NDVI_POST_FIRE = "ndvi_post_fire"
9
+ RBR = "rbr"
10
+
11
+ def __str__(self):
12
+ return self.value
@@ -0,0 +1,106 @@
1
+ # downloaders.py
2
+ import logging
3
+ import ee
4
+ import requests
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ # Local cache: { (bbox_key) → best_scale }
9
+ scale_cache = {}
10
+
11
+
12
+ def bbox_key(region: ee.Geometry):
13
+ """
14
+ Compute a stable and compact key for a geometry.
15
+
16
+ - Uses the bounding box
17
+ - Rounds coordinates to 3 decimal places
18
+ - Output: tuple usable as dict key
19
+ """
20
+ coords = region.bounds().coordinates().getInfo()
21
+ flat = [round(c, 3) for pair in coords[0] for c in pair]
22
+ return tuple(flat)
23
+
24
+ def download_image(
25
+ image: ee.Image,
26
+ region: ee.Geometry,
27
+ scale: int = 10,
28
+ format: str = "GEO_TIFF",
29
+ bands: list | None = None,
30
+ ) -> bytes:
31
+ """
32
+ Generic and robust Earth Engine image downloader with caching.
33
+
34
+ - If a successful scale was already found for this region, reuses it directly.
35
+ - Otherwise tries scales: scale → scale+15 → ... → 150.
36
+ - Caches the first scale that works for future downloads.
37
+ - Works for both single-band (TIFF) and multi-band images.
38
+ """
39
+
40
+ region_id = bbox_key(region)
41
+
42
+ # Select band(s) if needed
43
+ img = image.select(bands) if bands else image
44
+
45
+ # Try using cached scale first (fast path)
46
+ if region_id in scale_cache:
47
+ cached_scale = scale_cache[region_id]
48
+
49
+ try:
50
+ logger.info(f"Using cached scale {cached_scale} m for region {region_id}")
51
+ url = img.getDownloadURL({
52
+ "scale": cached_scale,
53
+ "region": region,
54
+ "format": format
55
+ })
56
+
57
+ resp = requests.get(url, stream=True)
58
+ resp.raise_for_status()
59
+
60
+ logger.info(f"Downloaded successfully with cached scale {cached_scale} m")
61
+ return resp.content
62
+
63
+ except Exception as e:
64
+ logger.warning(
65
+ f"Cached scale {cached_scale} m failed ({e}). Will try fallback loop."
66
+ )
67
+ # continue to fallback progressive search
68
+
69
+ # Progressive search for a working scale (slow path)
70
+ for attempt_scale in range(scale, 151, 15):
71
+
72
+ try:
73
+ url = img.getDownloadURL({
74
+ "scale": attempt_scale,
75
+ "region": region,
76
+ "format": format
77
+ })
78
+
79
+ resp = requests.get(url, stream=True)
80
+ resp.raise_for_status()
81
+
82
+ logger.info(f"Downloaded successfully at {attempt_scale} m")
83
+
84
+ # SAVE the working scale in cache for future images
85
+ scale_cache[region_id] = attempt_scale
86
+ logger.info(
87
+ f"Caching scale {attempt_scale} m for region {region_id}"
88
+ )
89
+
90
+ return resp.content
91
+
92
+ except Exception as e:
93
+ # classic GEE “too large” error
94
+ if "Total request size" in str(e):
95
+ logger.info(
96
+ f"Scale {attempt_scale} m rejected (too large). Trying next..."
97
+ )
98
+ continue
99
+
100
+ # other error → raise immediately
101
+ raise
102
+
103
+ # No scale worked up to 150 m
104
+ raise RuntimeError(
105
+ "Unable to download image even at 150 m — region too large."
106
+ )
@@ -0,0 +1,15 @@
1
+ # fire_severity.py
2
+ from enum import IntEnum
3
+
4
+ class FireSeverity(IntEnum):
5
+ UNBURNED = (0, "Unburned")
6
+ LOW = (1, "Low")
7
+ MODERATE = (2, "Moderate")
8
+ HIGH = (3, "High")
9
+ VERY_HIGH = (4, "Very High")
10
+
11
+ def __new__(cls, value, label):
12
+ obj = int.__new__(cls, value)
13
+ obj._value_ = value
14
+ obj.label = label
15
+ return obj
@@ -0,0 +1,454 @@
1
+ # post_fire_assessment.py
2
+ import logging
3
+ import json
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+ import time
7
+
8
+ import ee
9
+ from rasterio.io import MemoryFile
10
+
11
+ from wildfire_analyser.fire_assessment.date_utils import expand_dates
12
+ from wildfire_analyser.fire_assessment.deliverable import Deliverable
13
+ from wildfire_analyser.fire_assessment.validators import (
14
+ validate_date,
15
+ validate_geojson_path,
16
+ validate_deliverables,
17
+ ensure_not_empty
18
+ )
19
+ from wildfire_analyser.fire_assessment.downloaders import download_image
20
+
21
+ CLOUD_THRESHOLD = 70
22
+ COLLECTION_ID = "COPERNICUS/S2_SR_HARMONIZED"
23
+ DAYS_BEFORE_AFTER = 30
24
+ IMAGE_SCALE = 10
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class PostFireAssessment:
30
+ def __init__(self, gee_key_json: str, geojson_path: str, start_date: str, end_date: str,
31
+ deliverables=None, track_timings: bool = False):
32
+ # Validate input parameters
33
+ validate_geojson_path(geojson_path)
34
+ validate_date(start_date, "start_date")
35
+ validate_date(end_date, "end_date")
36
+ validate_deliverables(deliverables)
37
+
38
+ # Check chronological order
39
+ if start_date > end_date:
40
+ raise ValueError(f"'start_date' must be earlier than 'end_date'. Received: {start_date} > {end_date}")
41
+
42
+ # Store parameters
43
+ self.gee = self.gee_authenticate(gee_key_json)
44
+ self.roi = self.load_geojson(geojson_path)
45
+ self.start_date = start_date
46
+ self.end_date = end_date
47
+ self.deliverables = deliverables or []
48
+ self.track_timings = track_timings
49
+
50
+ def gee_authenticate(self, gee_key_json: str) -> ee:
51
+ """
52
+ Authenticate to Google Earth Engine using a service account key JSON.
53
+ """
54
+ # Converte a string JSON para dicionário
55
+ try:
56
+ key_dict = json.loads(gee_key_json)
57
+ except json.JSONDecodeError as e:
58
+ raise ValueError(f"Error decoding GEE_PRIVATE_KEY_JSON: {e}") from e
59
+
60
+ # Inicializa GEE usando arquivo temporário
61
+ try:
62
+ with NamedTemporaryFile(mode="w+", suffix=".json") as f:
63
+ json.dump(key_dict, f)
64
+ f.flush()
65
+ credentials = ee.ServiceAccountCredentials(key_dict["client_email"], f.name)
66
+ ee.Initialize(credentials)
67
+ except Exception as e:
68
+ raise RuntimeError(f"Failed to authenticate with Google Earth Engine: {e}") from e
69
+
70
+ return ee
71
+
72
+ def load_geojson(self, path: str) -> ee.Geometry:
73
+ """Load a GeoJSON file and return an Earth Engine Geometry."""
74
+ file_path = Path(path)
75
+
76
+ if not file_path.exists():
77
+ raise FileNotFoundError(f"GeoJSON not found: {path}")
78
+
79
+ with open(file_path, 'r') as f:
80
+ geojson = json.load(f)
81
+
82
+ # Converts GeoJSON to EE geometry
83
+ try:
84
+ geometry = ee.Geometry(geojson['features'][0]['geometry'])
85
+ except Exception as e:
86
+ raise ValueError(f"Invalid GeoJSON geometry: {e}") from e
87
+
88
+ return geometry
89
+
90
+ def _load_full_collection(self):
91
+ """Load all images intersecting ROI under cloud threshold, mask clouds, select bands, add reflectance."""
92
+ bands_to_select = ['B2', 'B3', 'B4', 'B8', 'B12', 'QA60']
93
+
94
+ def mask_s2_clouds(img):
95
+ qa = img.select('QA60')
96
+ cloud = qa.bitwiseAnd(1 << 10).neq(0)
97
+ cirrus = qa.bitwiseAnd(1 << 11).neq(0)
98
+ mask = cloud.Or(cirrus).Not()
99
+ return img.updateMask(mask)
100
+
101
+ collection = (
102
+ self.gee.ImageCollection(COLLECTION_ID)
103
+ .filterBounds(self.roi)
104
+ .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', CLOUD_THRESHOLD))
105
+ .map(mask_s2_clouds)
106
+ .sort('CLOUDY_PIXEL_PERCENTAGE', False)
107
+ )
108
+
109
+ # Function to add reflectance (_refl).
110
+ def preprocess(img):
111
+ refl_bands = img.select(bands_to_select).multiply(0.0001)
112
+ refl_names = refl_bands.bandNames().map(lambda b: ee.String(b).cat('_refl'))
113
+ img = img.addBands(refl_bands.rename(refl_names))
114
+ return img
115
+
116
+ collection = collection.map(preprocess)
117
+
118
+ return collection
119
+
120
+ def merge_bands(self, band_tiffs: dict[str, bytes]) -> bytes:
121
+ """
122
+ Merge multiple single-band GeoTIFFs (raw bytes) into a single multi-band GeoTIFF.
123
+ """
124
+ memfiles = {b: MemoryFile(tiff_bytes) for b, tiff_bytes in band_tiffs.items()}
125
+ datasets = {b: memfiles[b].open() for b in memfiles}
126
+
127
+ # Reference band to copy metadata
128
+ first = next(iter(datasets.values()))
129
+ profile = first.profile.copy()
130
+ profile.update(count=len(datasets))
131
+
132
+ # Merge bands
133
+ with MemoryFile() as merged_mem:
134
+ with merged_mem.open(**profile) as dst:
135
+ for idx, (band, ds) in enumerate(datasets.items(), start=1):
136
+ dst.write(ds.read(1), idx)
137
+
138
+ return merged_mem.read()
139
+
140
+ def _generate_rgb_pre_fire(self, mosaic: ee.Image) -> dict:
141
+ """
142
+ Generates two GeoTIFF and JPEG images.
143
+ """
144
+ # Generate the technical multi-band RGB GeoTIFF
145
+ tiff = self._generate_rgb(mosaic, Deliverable.RGB_PRE_FIRE.value)
146
+
147
+ # Generate the visual RGB JPEG (with overlay)
148
+ rgb_img = mosaic.select(['B4_refl', 'B3_refl', 'B2_refl'])
149
+ vis_params = {"min": 0.0, "max": 0.3}
150
+ jpeg = self._generate_visual_image(rgb_img, "rgb_pre_fire_visual", vis_params)
151
+
152
+ return tiff, jpeg
153
+
154
+ def _generate_rgb_post_fire(self, mosaic: ee.Image) -> dict:
155
+ """
156
+ Generates two GeoTIFF and JPEG images.
157
+ """
158
+ # Generate the technical multi-band RGB GeoTIFF
159
+ tiff = self._generate_rgb(mosaic, Deliverable.RGB_POST_FIRE.value)
160
+
161
+ # Generate the visual RGB JPEG (with overlay)
162
+ rgb_img = mosaic.select(['B4_refl', 'B3_refl', 'B2_refl'])
163
+ vis_params = {"min": 0.0, "max": 0.3}
164
+ jpeg = self._generate_visual_image(rgb_img, "rgb_post_fire_visual", vis_params)
165
+
166
+ return tiff, jpeg
167
+
168
+ def _generate_rgb(self, mosaic, filename_prefix):
169
+ """
170
+ Generates an RGB (B4, B3, B2) as a single multiband GeoTIFF.
171
+ """
172
+ # Merges into a single multiband TIFF.
173
+ image_bytes = self.merge_bands({
174
+ "B4_refl": download_image(image=mosaic, bands=['B4_refl'], region=self.roi, scale=IMAGE_SCALE, format="GEO_TIFF"),
175
+ "B3_refl": download_image(image=mosaic, bands=['B3_refl'], region=self.roi, scale=IMAGE_SCALE, format="GEO_TIFF"),
176
+ "B2_refl": download_image(image=mosaic, bands=['B2_refl'], region=self.roi, scale=IMAGE_SCALE, format="GEO_TIFF"),
177
+ })
178
+
179
+ return {
180
+ "filename": f"{filename_prefix}.tif",
181
+ "content_type": "image/tiff",
182
+ "data": image_bytes
183
+ }
184
+
185
+ def _generate_visual_image(self, img: ee.Image, filename: str, vis_params: dict) -> dict:
186
+ """
187
+ Generates a JPEG of an Earth Engine image with styled ROI overlay.
188
+ """
189
+ vis = img.visualize(**vis_params)
190
+ overlay = self._styled_roi_overlay().visualize()
191
+ final = vis.blend(overlay)
192
+
193
+ jpeg_bytes = download_image(
194
+ image=final,
195
+ region=self.roi,
196
+ scale=IMAGE_SCALE,
197
+ format="JPEG"
198
+ )
199
+
200
+ # Return dict just like before
201
+ return {
202
+ "filename": f"{filename}.jpg",
203
+ "content_type": "image/jpeg",
204
+ "data": jpeg_bytes
205
+ }
206
+
207
+ def _generate_ndvi(self, mosaic: ee.Image, filename: str) -> dict:
208
+ """
209
+ Computes NDVI from a mosaic using reflectance bands (B8_refl and B4_refl).
210
+ Downloads the resulting index as a single-band GeoTIFF and returns it as a
211
+ deliverable object.
212
+ """
213
+ data = download_image(image=mosaic, bands=['ndvi'], region=self.roi, scale=IMAGE_SCALE, format="GEO_TIFF")
214
+ return {
215
+ "filename": f"{filename}.tif",
216
+ "content_type": "image/tiff",
217
+ "data": data
218
+ }
219
+
220
+ def _generate_rbr(self, rbr_img: ee.Image, severity_img: ee.Image) -> tuple[dict, dict, dict]:
221
+ """
222
+ Computes RBR and generates deliverables:
223
+ - rbr.tif (GeoTIFF)
224
+ - rbr_severity_visual.jpg (RBR class color)
225
+ - rbr_visual.jpg (RBR color JPEG with rbrVis palette)
226
+ """
227
+ # GeoTIFF
228
+ image_bytes = download_image(image=rbr_img, bands=['rbr'], region=self.roi, scale=IMAGE_SCALE, format="GEO_TIFF")
229
+ tiff_deliverable = {
230
+ "filename": "rbr.tif",
231
+ "content_type": "image/tiff",
232
+ "data": image_bytes
233
+ }
234
+
235
+ # Visual JPEG
236
+ vis_params = {"min": -0.5, "max": 0.6, "palette": ["black", "yellow", "red"]}
237
+ visual_deliverable = self._generate_visual_image(rbr_img, "rbr_visual", vis_params)
238
+
239
+ # Severity visual JPEG
240
+ severity_vis_params = {
241
+ "min": 0,
242
+ "max": 4,
243
+ "palette": ["00FF00","FFFF00","FFA500","FF0000","8B4513"]
244
+ }
245
+ severity_visual_deliverable = self._generate_visual_image(severity_img, "rbr_severity_visual", severity_vis_params)
246
+
247
+ return tiff_deliverable, severity_visual_deliverable, visual_deliverable
248
+
249
+ def _styled_roi_overlay(self):
250
+ """Creates a styled overlay of the ROI polygon (purple outline, no fill)."""
251
+ fc = ee.FeatureCollection([ee.Feature(self.roi)])
252
+ styled = fc.style(
253
+ color='#800080', # purple border
254
+ fillColor='00000000', # transparent fill
255
+ width=3
256
+ )
257
+ return styled
258
+
259
+ def _build_mosaic_with_indexes(self, collection: ee.ImageCollection) -> ee.Image:
260
+ """
261
+ Takes a filtered collection → builds a mosaic → computes NDVI and
262
+ NBR → returns a mosaic with the additional bands.
263
+ """
264
+ mosaic = collection.mosaic()
265
+ ndvi = mosaic.normalizedDifference(["B8_refl", "B4_refl"]).rename("ndvi")
266
+ nbr = mosaic.normalizedDifference(["B8_refl", "B12_refl"]).rename("nbr")
267
+ return mosaic.addBands([ndvi, nbr])
268
+
269
+ def _compute_rbr(self, before_mosaic: ee.Image, after_mosaic: ee.Image) -> ee.Image:
270
+ """
271
+ Computes RBR (Relative Burn Ratio) from BEFORE and AFTER mosaics.
272
+ Assumes both mosaics already include band 'nbr'.
273
+ """
274
+ delta_nbr = before_mosaic.select('nbr').subtract(after_mosaic.select('nbr')).rename('dnbr')
275
+ rbr = delta_nbr.divide(before_mosaic.select('nbr').add(1.001)).rename('rbr')
276
+ return rbr
277
+
278
+ def _compute_area_by_severity(self, severity_img: ee.Image) -> dict[int, float]:
279
+ """
280
+ Calculates the area per class (in hectares) within the ROI in an optimized way.
281
+ """
282
+ # 1 Sentinel-2 pixel = 10 m → pixel area = 100 m² = 0.01 ha
283
+ pixel_area_ha = ee.Image.pixelArea().divide(10000)
284
+
285
+ # Creates an image using 'severity' as a mask for each class
286
+ def area_per_class(c):
287
+ mask = severity_img.eq(c)
288
+ return pixel_area_ha.updateMask(mask).rename('area_' + str(c))
289
+
290
+ class_images = [area_per_class(c) for c in range(5)]
291
+ stacked = ee.Image.cat(class_images)
292
+
293
+ # Reduces all bands simultaneously
294
+ areas = stacked.reduceRegion(
295
+ reducer=ee.Reducer.sum(),
296
+ geometry=self.roi,
297
+ scale=IMAGE_SCALE,
298
+ maxPixels=1e12
299
+ ).getInfo()
300
+
301
+ return { c: float(areas.get(f'area_{c}', 0) or 0) for c in range(5) }
302
+
303
+ def _classify_rbr_severity(self, rbr_img: ee.Image) -> ee.Image:
304
+ """
305
+ Classify RBR by severity:
306
+ 0 = Unburned (RBR < 0.1)
307
+ 1 = Low (0.1 ≤ RBR < 0.27)
308
+ 2 = Moderate (0.27 ≤ RBR < 0.44)
309
+ 3 = High (0.44 ≤ RBR < 0.66)
310
+ 4 = Very High (RBR ≥ 0.66)
311
+ """
312
+
313
+ severity = rbr_img.expression(
314
+ """
315
+ (b('rbr') < 0.10) ? 0 :
316
+ (b('rbr') < 0.27) ? 1 :
317
+ (b('rbr') < 0.44) ? 2 :
318
+ (b('rbr') < 0.66) ? 3 :
319
+ 4
320
+ """
321
+ ).rename("severity")
322
+
323
+ return severity
324
+
325
+ def format_severity_table(self, area_dict: dict[int, float]) -> list[dict]:
326
+ """
327
+ Converts the raw {severity: hectares} dict into a structured JSON
328
+ with severity, severity_name, ha, percent, and color.
329
+ """
330
+
331
+ severity_names = {
332
+ 0: "Unburned",
333
+ 1: "Low",
334
+ 2: "Moderate",
335
+ 3: "High",
336
+ 4: "Very High",
337
+ }
338
+
339
+ severity_colors = {
340
+ 0: "Green",
341
+ 1: "Yellow",
342
+ 2: "Orange",
343
+ 3: "Red",
344
+ 4: "Maroon",
345
+ }
346
+
347
+ total_ha = sum(area_dict.values()) or 1 # avoid division by zero
348
+
349
+ table = []
350
+ for s in range(5):
351
+ ha = float(area_dict.get(s, 0))
352
+ pct = (ha / total_ha) * 100
353
+
354
+ table.append({
355
+ "severity": s,
356
+ "severity_name": severity_names[s],
357
+ "ha": round(ha, 2),
358
+ "percent": round(pct, 2),
359
+ "color": severity_colors[s]
360
+ })
361
+
362
+ return table
363
+
364
+ def force_execution(self, obj):
365
+ """
366
+ Forces GEE to execute pending computations while retrieving the smallest possible data.
367
+ """
368
+ try:
369
+ # Collections → safest, smallest fetch possible
370
+ if isinstance(obj, ee.ImageCollection) or isinstance(obj, ee.FeatureCollection):
371
+ return obj.size().getInfo()
372
+
373
+ # Images → never call getInfo() directly (too heavy)
374
+ if isinstance(obj, ee.Image):
375
+ # Use a tiny region and simple stats to force execution
376
+ # without downloading the full image
377
+ test = obj.reduceRegion(
378
+ reducer=ee.Reducer.mean(),
379
+ geometry=self.roi.centroid(),
380
+ scale=100,
381
+ maxPixels=1e9
382
+ )
383
+ return test.getInfo()
384
+
385
+ # Numbers / Dictionaries / anything else
386
+ return obj.getInfo()
387
+
388
+ except Exception:
389
+ return None
390
+
391
+ def run_analysis(self):
392
+ timings = {}
393
+
394
+ # Load satellite collection
395
+ if self.track_timings: t0 = time.time()
396
+ full_collection = self._load_full_collection()
397
+ if self.track_timings:
398
+ self.force_execution(full_collection)
399
+ timings["Sat collection loaded"] = time.time() - t0
400
+
401
+ # Expand dates to maximize satellite image coverage
402
+ before_start, before_end, after_start, after_end = expand_dates(
403
+ self.start_date, self.end_date, DAYS_BEFORE_AFTER
404
+ )
405
+
406
+ # Build pre fire mosaic
407
+ if self.track_timings: t1 = time.time()
408
+ before_collection = full_collection.filterDate(before_start, before_end)
409
+ ensure_not_empty(before_collection, before_start, before_end)
410
+ before_mosaic = self._build_mosaic_with_indexes(before_collection)
411
+
412
+ # Build post fire mosaic
413
+ after_collection = full_collection.filterDate(after_start, after_end)
414
+ ensure_not_empty(after_collection, after_start, after_end)
415
+ after_mosaic = self._build_mosaic_with_indexes(after_collection)
416
+
417
+ # Compute RBR
418
+ rbr = self._compute_rbr(before_mosaic, after_mosaic)
419
+
420
+ # Classification and severity extension calculation
421
+ severity = self._classify_rbr_severity(rbr)
422
+ area_stats = self._compute_area_by_severity(severity)
423
+ if self.track_timings:
424
+ self.force_execution(area_stats)
425
+ timings["Indexes calculated"] = time.time() - t1
426
+
427
+ deliverable_registry = {
428
+ Deliverable.RGB_PRE_FIRE: lambda ctx: self._generate_rgb_pre_fire(before_mosaic),
429
+ Deliverable.RGB_POST_FIRE: lambda ctx: self._generate_rgb_post_fire(after_mosaic),
430
+ Deliverable.NDVI_PRE_FIRE: lambda ctx: [self._generate_ndvi(before_mosaic, Deliverable.NDVI_PRE_FIRE.value)],
431
+ Deliverable.NDVI_POST_FIRE: lambda ctx: [self._generate_ndvi(after_mosaic, Deliverable.NDVI_POST_FIRE.value)],
432
+ Deliverable.RBR: lambda ctx: self._generate_rbr(rbr, severity),
433
+ }
434
+
435
+ # Download binaries
436
+ if self.track_timings: t2 = time.time()
437
+ images = {}
438
+
439
+ for d in self.deliverables:
440
+ gen_fn = deliverable_registry.get(d)
441
+ outputs = gen_fn({})
442
+ if isinstance(outputs, tuple) or isinstance(outputs, list):
443
+ for out in outputs:
444
+ images[out["filename"]] = out
445
+ else:
446
+ images[outputs["filename"]] = outputs
447
+
448
+ if self.track_timings: timings["Images downloaded"] = time.time() - t2
449
+
450
+ return {
451
+ "images": images,
452
+ "timings": timings,
453
+ "area_by_severity": self.format_severity_table(area_stats)
454
+ }
@@ -0,0 +1,60 @@
1
+ # validators.py
2
+ import os
3
+ import re
4
+ import ee
5
+ from datetime import datetime
6
+ from wildfire_analyser.fire_assessment.deliverable import Deliverable
7
+
8
+ DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
9
+
10
+
11
+ def validate_date(value: str, field_name: str) -> None:
12
+ """Validate date format YYYY-MM-DD."""
13
+ if not isinstance(value, str):
14
+ raise ValueError(f"{field_name} must be a string in format YYYY-MM-DD.")
15
+
16
+ if not re.match(DATE_PATTERN, value):
17
+ raise ValueError(f"{field_name} must follow format YYYY-MM-DD.")
18
+
19
+ try:
20
+ datetime.strptime(value, "%Y-%m-%d")
21
+ except Exception:
22
+ raise ValueError(f"{field_name} is not a valid calendar date.")
23
+
24
+
25
+ def validate_geojson_path(path: str) -> None:
26
+ """Validate that path exists, is a file, and ends with .geojson."""
27
+ if not isinstance(path, str):
28
+ raise ValueError("geojson_path must be a string.")
29
+
30
+ if not path.endswith(".geojson"):
31
+ raise ValueError("geojson_path must end with .geojson")
32
+
33
+ if not os.path.isfile(path):
34
+ raise ValueError(f"geojson_path does not exist: {path}")
35
+
36
+
37
+ def validate_deliverables(deliverables: list | None) -> None:
38
+ """
39
+ Validate deliverables list.
40
+ If None → allowed (client wants all deliverables).
41
+ If list → ensure each item is Deliverable.
42
+ """
43
+ if deliverables is None:
44
+ return # valid, user wants default behavior
45
+
46
+ if not isinstance(deliverables, list):
47
+ raise ValueError("deliverables must be a list of Deliverable values.")
48
+
49
+ invalid = [d for d in deliverables if not isinstance(d, Deliverable)]
50
+ if invalid:
51
+ raise ValueError(f"Invalid deliverables: {invalid}")
52
+
53
+ def ensure_not_empty(collection: ee.ImageCollection, start: str, end: str) -> None:
54
+ try:
55
+ size_val = collection.size().getInfo()
56
+ except Exception:
57
+ size_val = 0
58
+
59
+ if size_val == 0:
60
+ raise ValueError(f"No images found in date range {start} → {end}")
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: wildfire-analyser
3
+ Version: 0.1.10
4
+ Summary: Python library for post-fire assessment and wildfire analysis using Google Earth Engine.
5
+ Author: Marcelo Camargo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/camargo-advanced/wildfire-analyser
8
+ Project-URL: Source, https://github.com/camargo-advanced/wildfire-analyser
9
+ Project-URL: Issues, https://github.com/camargo-advanced/wildfire-analyser/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: geemap==0.36.6
14
+ Requires-Dist: geopandas==1.1.1
15
+ Requires-Dist: geedim==2.0.0
16
+ Requires-Dist: python-dotenv==1.0.1
17
+ Dynamic: license-file
18
+
19
+ # wildfire-analyser
20
+
21
+ Python project for analyzing wildfires in natural reserves.
22
+
23
+ ## Installation and Usage
24
+
25
+ Follow the steps below to install and test `wildfire-analyser` inside an isolated environment:
26
+
27
+ ```bash
28
+ mkdir /tmp/test
29
+ cd /tmp/test
30
+
31
+ python3 -m venv venv
32
+ source venv/bin/activate
33
+
34
+ pip install wildfire-analyser
35
+ ```
36
+
37
+ ### Required Files Before Running the Client
38
+
39
+ Before running the client, you **must** prepare two items:
40
+
41
+ ---
42
+
43
+ #### **1. Add a GeoJSON polygon**
44
+
45
+ Create a folder named `polygons` in the project root and place your ROI polygon file inside it:
46
+
47
+ ```
48
+ /tmp/test/
49
+ ├── polygons/
50
+ │ └── your_polygon.geojson
51
+ └── venv/
52
+ ```
53
+
54
+ An example GeoJSON file is available in the repository.
55
+
56
+ ---
57
+
58
+ #### **2. Create the `.env` file with GEE authentication data**
59
+
60
+ In the project root, add a `.env` file containing your Google Earth Engine authentication variables.
61
+
62
+ A `.env` template is also available in the GitHub repository.
63
+
64
+ ```
65
+ /tmp/test/
66
+ ├── .env
67
+ ├── polygons/
68
+ └── venv/
69
+ ```
70
+
71
+ ---
72
+
73
+ ### Running the Client
74
+
75
+ After adding the `.env` file and your GeoJSON polygon:
76
+
77
+ ```bash
78
+ python3 -m wildfire_analyser.client
79
+ ```
80
+
81
+ This will start the analysis process, generate the configured deliverables, and save the output files in the current directory.
82
+
83
+
84
+
85
+ ## Setup Instructions for Developers
86
+
87
+ 1. **Clone the repository**
88
+
89
+ ```bash
90
+ git clone git@github.com:camargo-advanced/wildfire-analyser.git
91
+ cd wildfire-analyser
92
+ ```
93
+
94
+ 2. **Create a virtual environment**
95
+
96
+ ```bash
97
+ python3 -m venv venv
98
+ ```
99
+
100
+ 3. **Activate the virtual environment**
101
+
102
+ ```bash
103
+ source venv/bin/activate
104
+ ```
105
+
106
+ 4. **Install dependencies**
107
+ Make sure the virtual environment is activated, then run:
108
+
109
+ ```bash
110
+ pip install -r requirements.txt
111
+ ```
112
+
113
+ 5. **Configure environment variables**
114
+ Copy your version of `.env` file to the root folder with your GEE authentication credentials. A template file `.env.template` is provided as an example.
115
+
116
+ 6. **Run the sample client application**
117
+
118
+ ```bash
119
+ python3 -m wildfire_analyser.client
120
+ ```
121
+
122
+ ## Useful Commands
123
+
124
+ * **Deactivate the virtual environment**:
125
+
126
+ ```bash
127
+ deactivate
128
+ ```
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ wildfire_analyser/__init__.py
5
+ wildfire_analyser/client.py
6
+ wildfire_analyser.egg-info/PKG-INFO
7
+ wildfire_analyser.egg-info/SOURCES.txt
8
+ wildfire_analyser.egg-info/dependency_links.txt
9
+ wildfire_analyser.egg-info/requires.txt
10
+ wildfire_analyser.egg-info/top_level.txt
11
+ wildfire_analyser/fire_assessment/__init__.py
12
+ wildfire_analyser/fire_assessment/date_utils.py
13
+ wildfire_analyser/fire_assessment/deliverable.py
14
+ wildfire_analyser/fire_assessment/downloaders.py
15
+ wildfire_analyser/fire_assessment/fire_severity.py
16
+ wildfire_analyser/fire_assessment/post_fire_assessment.py
17
+ wildfire_analyser/fire_assessment/validators.py
@@ -0,0 +1,4 @@
1
+ geemap==0.36.6
2
+ geopandas==1.1.1
3
+ geedim==2.0.0
4
+ python-dotenv==1.0.1
@@ -0,0 +1 @@
1
+ wildfire_analyser