File size: 8,517 Bytes
d5b0af1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""
fetch_firms.py — NASA FIRMS API fetcher for active fire detection data.

Downloads VIIRS_SNPP_NRT fire detection points for a given bounding box,
filters by confidence threshold, and converts fire points to binary
128×128 label masks. Falls back to realistic synthetic data when the API
is unavailable or no API key is configured.
"""

import csv
import io
import logging
from pathlib import Path
from typing import List, Dict, Tuple, Optional

import numpy as np
import requests

from src.training.config import (
    NASA_FIRMS_API_KEY, FIRMS_BASE_URL, FIRMS_RAW_DIR,
    PATCH_SIZE, FIRE_CONFIDENCE_THRESHOLD, NUM_SYNTHETIC_SAMPLES,
    IMAGE_PATCHES_DIR,
)

logger = logging.getLogger(__name__)


def fetch_firms_csv(
    bbox: str = "-122.0,36.0,-118.0,39.0",
    days: int = 7,
    api_key: Optional[str] = None,
) -> Optional[List[Dict]]:
    """
    Fetch active fire detections from NASA FIRMS API.

    Args:
        bbox: Bounding box as 'west,south,east,north'.
        days: Number of recent days to query (max 10).
        api_key: NASA FIRMS API key. Falls back to config if not provided.

    Returns:
        List of fire detection dicts, or None if the request fails.
    """
    key = api_key or NASA_FIRMS_API_KEY
    if not key:
        logger.warning("No NASA FIRMS API key configured — using synthetic data.")
        return None

    url = f"{FIRMS_BASE_URL}/{key}/VIIRS_SNPP_NRT/{bbox}/{days}"
    logger.info(f"Fetching FIRMS data: {url}")

    try:
        response = requests.get(url, timeout=30)
        response.raise_for_status()
        reader = csv.DictReader(io.StringIO(response.text))
        records = list(reader)
        logger.info(f"Received {len(records)} fire detections from FIRMS.")
        return records
    except requests.RequestException as e:
        logger.error(f"FIRMS API request failed: {e}")
        return None


def filter_high_confidence(records: List[Dict], threshold: int = FIRE_CONFIDENCE_THRESHOLD) -> List[Dict]:
    """Keep only detections with confidence >= threshold."""
    filtered = []
    for r in records:
        try:
            conf = int(r.get("confidence", 0))
            if conf >= threshold:
                filtered.append(r)
        except (ValueError, TypeError):
            continue
    logger.info(f"Filtered to {len(filtered)} high-confidence detections (≥{threshold}%).")
    return filtered


def fire_points_to_mask(
    records: List[Dict],
    bbox: str,
    patch_size: int = PATCH_SIZE,
) -> np.ndarray:
    """
    Convert fire point detections to a binary spatial mask.

    Each fire detection's lat/lon is mapped into a (patch_size × patch_size)
    grid based on the bounding box. Pixels with at least one fire detection
    are set to 1.0; others remain 0.0.

    Args:
        records: List of dicts with 'latitude' and 'longitude' keys.
        bbox: 'west,south,east,north' string.
        patch_size: Output spatial resolution.

    Returns:
        Binary mask of shape (patch_size, patch_size).
    """
    west, south, east, north = [float(x) for x in bbox.split(",")]
    mask = np.zeros((patch_size, patch_size), dtype=np.float32)

    for r in records:
        try:
            lat = float(r["latitude"])
            lon = float(r["longitude"])

            # Map lat/lon to pixel indices
            col = int((lon - west) / (east - west) * (patch_size - 1))
            row = int((north - lat) / (north - south) * (patch_size - 1))

            # Clamp to valid range
            row = max(0, min(row, patch_size - 1))
            col = max(0, min(col, patch_size - 1))

            # Set a small region around the fire point (simulate spread)
            radius = np.random.randint(1, 4)
            for dr in range(-radius, radius + 1):
                for dc in range(-radius, radius + 1):
                    rr = max(0, min(row + dr, patch_size - 1))
                    cc = max(0, min(col + dc, patch_size - 1))
                    mask[rr, cc] = 1.0
        except (KeyError, ValueError):
            continue

    return mask


def generate_synthetic_fire_data(
    num_samples: int = NUM_SYNTHETIC_SAMPLES,
    patch_size: int = PATCH_SIZE,
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Generate synthetic satellite image patches and fire risk masks.

    Creates realistic-looking data with:
    - 4-channel (RGB + NIR) image patches with terrain-like patterns
    - Binary fire masks with clustered fire regions

    Args:
        num_samples: Number of samples to generate.
        patch_size: Spatial resolution of each patch.

    Returns:
        Tuple of (images, masks) arrays with shapes
        (N, 4, H, W) and (N, H, W).
    """
    logger.info(f"Generating {num_samples} synthetic fire samples...")
    images = np.zeros((num_samples, 4, patch_size, patch_size), dtype=np.float32)
    masks = np.zeros((num_samples, patch_size, patch_size), dtype=np.float32)

    for i in range(num_samples):
        # Create base terrain pattern using Perlin-like noise (smooth random)
        # Use multi-scale random noise for natural appearance
        base = np.random.rand(patch_size // 8, patch_size // 8).astype(np.float32)
        from scipy.ndimage import zoom
        try:
            terrain = zoom(base, 8, order=3)[:patch_size, :patch_size]
        except ImportError:
            terrain = np.random.rand(patch_size, patch_size).astype(np.float32)

        # RGB channels: greenish-brown terrain
        images[i, 0] = terrain * 0.4 + 0.1  # Red
        images[i, 1] = terrain * 0.5 + 0.2  # Green
        images[i, 2] = terrain * 0.2 + 0.05  # Blue
        images[i, 3] = terrain * 0.7 + 0.1  # NIR (vegetation reflects strongly)

        # Add fire-like bright spots to some images
        has_fire = np.random.rand() > 0.3  # 70% of samples have fires
        if has_fire:
            num_clusters = np.random.randint(1, 5)
            for _ in range(num_clusters):
                cy = np.random.randint(10, patch_size - 10)
                cx = np.random.randint(10, patch_size - 10)
                radius = np.random.randint(3, 12)

                # Create circular fire region in mask
                yy, xx = np.mgrid[:patch_size, :patch_size]
                dist = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
                fire_region = dist < radius
                masks[i][fire_region] = 1.0

                # Make fire regions bright in RGB, dark in NIR
                images[i, 0][fire_region] += 0.5  # Bright red
                images[i, 1][fire_region] += 0.2
                images[i, 3][fire_region] -= 0.3  # Low NIR (burned vegetation)

            # Smooth the geometric mask into an organic blob
            import cv2
            mask_float = masks[i].astype(np.float32)
            mask_smoothed = cv2.GaussianBlur(mask_float, (15, 15), 5)
            masks[i] = mask_smoothed / (mask_smoothed.max() + 1e-8)

        # Clamp to [0, 1]
        images[i] = np.clip(images[i], 0.0, 1.0)

    logger.info(f"Generated {num_samples} synthetic samples. "
                f"Fire prevalence: {masks.sum(axis=(1,2)).mean():.1f} px/sample")
    return images, masks


def fetch_and_prepare_firms(
    bbox: str = "-122.0,36.0,-118.0,39.0",
    days: int = 7,
    save: bool = True,
) -> Tuple[np.ndarray, np.ndarray]:
    """
    End-to-end: fetch FIRMS data or generate synthetic fallback.

    Returns:
        Tuple of (images, masks).
    """
    records = fetch_firms_csv(bbox=bbox, days=days)

    if records is not None and len(records) > 0:
        filtered = filter_high_confidence(records)
        mask = fire_points_to_mask(filtered, bbox)

        # For real data, we still need synthetic image patches since
        # FIRMS only provides point data, not satellite imagery
        images, _ = generate_synthetic_fire_data(num_samples=1)
        images = images[0:1]
        masks = mask[np.newaxis, ...]

        logger.info("Using real FIRMS fire locations mapped to synthetic patches.")
    else:
        images, masks = generate_synthetic_fire_data()
        logger.info("Using fully synthetic fire data as FIRMS fallback.")

    if save:
        np.save(IMAGE_PATCHES_DIR / "satellite_patches.npy", images)
        np.save(IMAGE_PATCHES_DIR / "fire_masks.npy", masks)
        logger.info(f"Saved patches to {IMAGE_PATCHES_DIR}")

    return images, masks


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    images, masks = fetch_and_prepare_firms()
    print(f"Images: {images.shape}, Masks: {masks.shape}")