Spaces:
Sleeping
Sleeping
Fix smoke plume artifacts: realistic HMS thresholds, light smoothing/morphology for polygons, proper contour grid axes, and legend sync
Browse files
app.py
CHANGED
|
@@ -185,7 +185,7 @@ class FoliumSmokeRenderer:
|
|
| 185 |
<div style="margin: 5px 0;">
|
| 186 |
<span style="display: inline-block; width: 20px; height: 15px;
|
| 187 |
background-color: #E8E8E8; border: 1px solid #CCC; margin-right: 5px;"></span>
|
| 188 |
-
<span style="font-size: 12px;">Light (
|
| 189 |
</div>
|
| 190 |
<div style="margin: 5px 0;">
|
| 191 |
<span style="display: inline-block; width: 20px; height: 15px;
|
|
@@ -272,12 +272,12 @@ class HMSSmokePolygonGenerator:
|
|
| 272 |
"""Generate HMS-style smoke plume polygons from HRRR-Smoke data"""
|
| 273 |
|
| 274 |
def __init__(self):
|
| 275 |
-
# HMS
|
| 276 |
-
#
|
| 277 |
self.density_categories = {
|
| 278 |
-
'light': {'min':
|
| 279 |
-
'medium': {'min':
|
| 280 |
-
'heavy': {'min':
|
| 281 |
}
|
| 282 |
|
| 283 |
# HMS visibility-based smoke detection thresholds (primary detection method)
|
|
@@ -496,7 +496,7 @@ class HMSSmokePolygonGenerator:
|
|
| 496 |
return classifications
|
| 497 |
|
| 498 |
def extract_smoke_polygons(self, lat2d, lon2d, smoke_values, min_area=0.01, is_hms_categorical=False):
|
| 499 |
-
"""Extract smoke plume polygons from gridded data
|
| 500 |
polygons = []
|
| 501 |
|
| 502 |
if not HMS_LIBS_AVAILABLE:
|
|
@@ -510,8 +510,14 @@ class HMSSmokePolygonGenerator:
|
|
| 510 |
classifications = smoke_values.astype(int)
|
| 511 |
print(f"Using HMS categorical data directly: {np.unique(classifications)}")
|
| 512 |
else:
|
| 513 |
-
#
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
# Process each density category
|
| 517 |
for category_name, category_info in self.density_categories.items():
|
|
@@ -528,9 +534,30 @@ class HMSSmokePolygonGenerator:
|
|
| 528 |
if not np.any(category_mask):
|
| 529 |
continue
|
| 530 |
|
| 531 |
-
#
|
| 532 |
-
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
|
| 535 |
# Find contours with simpler algorithm
|
| 536 |
try:
|
|
@@ -544,8 +571,8 @@ class HMSSmokePolygonGenerator:
|
|
| 544 |
if len(contour) < 4: # Need at least 4 points for polygon
|
| 545 |
continue
|
| 546 |
|
| 547 |
-
# Simplify contour for speed
|
| 548 |
-
step = 2 if category_name == '
|
| 549 |
simplified_contour = contour[::step]
|
| 550 |
|
| 551 |
# Convert pixel coordinates to lat/lon
|
|
@@ -1587,11 +1614,19 @@ class HRRRSmokeApp:
|
|
| 1587 |
# Try contour plotting
|
| 1588 |
try:
|
| 1589 |
opacity = 0.6 if polygons else 1.0 # Reduce opacity if polygons are shown
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1590 |
fig.add_trace(go.Contour(
|
| 1591 |
-
x=
|
| 1592 |
-
y=
|
| 1593 |
-
z=z2d
|
| 1594 |
colorscale=colorscale,
|
|
|
|
| 1595 |
showscale=True,
|
| 1596 |
opacity=opacity,
|
| 1597 |
colorbar=dict(
|
|
@@ -2438,4 +2473,4 @@ with gr.Blocks(title="HRRR Smoke Forecast", theme=gr.themes.Soft()) as app:
|
|
| 2438 |
""")
|
| 2439 |
|
| 2440 |
if __name__ == "__main__":
|
| 2441 |
-
app.launch(server_name="0.0.0.0", server_port=7860)
|
|
|
|
| 185 |
<div style="margin: 5px 0;">
|
| 186 |
<span style="display: inline-block; width: 20px; height: 15px;
|
| 187 |
background-color: #E8E8E8; border: 1px solid #CCC; margin-right: 5px;"></span>
|
| 188 |
+
<span style="font-size: 12px;">Light (3–15 µg/m³)</span>
|
| 189 |
</div>
|
| 190 |
<div style="margin: 5px 0;">
|
| 191 |
<span style="display: inline-block; width: 20px; height: 15px;
|
|
|
|
| 272 |
"""Generate HMS-style smoke plume polygons from HRRR-Smoke data"""
|
| 273 |
|
| 274 |
def __init__(self):
|
| 275 |
+
# HMS-style category thresholds expressed in µg/m³ (approximate, for visualization)
|
| 276 |
+
# These align with the legend and produce HMS-like plumes
|
| 277 |
self.density_categories = {
|
| 278 |
+
'light': {'min': 3, 'max': 15, 'color': '#FFFF88', 'description': 'Light smoke (3–15 µg/m³)'},
|
| 279 |
+
'medium': {'min': 15, 'max': 35, 'color': '#FFB366', 'description': 'Medium smoke (15–35 µg/m³)'},
|
| 280 |
+
'heavy': {'min': 35, 'max': 9999, 'color': '#FF6666', 'description': 'Heavy smoke (35+ µg/m³)'}
|
| 281 |
}
|
| 282 |
|
| 283 |
# HMS visibility-based smoke detection thresholds (primary detection method)
|
|
|
|
| 496 |
return classifications
|
| 497 |
|
| 498 |
def extract_smoke_polygons(self, lat2d, lon2d, smoke_values, min_area=0.01, is_hms_categorical=False):
|
| 499 |
+
"""Extract smoke plume polygons from gridded data with light smoothing for HMS-like shapes"""
|
| 500 |
polygons = []
|
| 501 |
|
| 502 |
if not HMS_LIBS_AVAILABLE:
|
|
|
|
| 510 |
classifications = smoke_values.astype(int)
|
| 511 |
print(f"Using HMS categorical data directly: {np.unique(classifications)}")
|
| 512 |
else:
|
| 513 |
+
# Lightly smooth the continuous field before categorization to avoid speckle
|
| 514 |
+
try:
|
| 515 |
+
from scipy import ndimage
|
| 516 |
+
smoothed = ndimage.gaussian_filter(smoke_values.astype(float), sigma=1.0)
|
| 517 |
+
except Exception:
|
| 518 |
+
smoothed = smoke_values
|
| 519 |
+
# Classify smoke densities from concentration values (µg/m³)
|
| 520 |
+
classifications = self.classify_smoke_density(smoothed)
|
| 521 |
|
| 522 |
# Process each density category
|
| 523 |
for category_name, category_info in self.density_categories.items():
|
|
|
|
| 534 |
if not np.any(category_mask):
|
| 535 |
continue
|
| 536 |
|
| 537 |
+
# Apply minimal morphology to reduce tiny blocky artifacts
|
| 538 |
+
try:
|
| 539 |
+
from scipy import ndimage
|
| 540 |
+
# Remove isolated noise, close small gaps
|
| 541 |
+
smoothed_mask = ndimage.binary_opening(category_mask, iterations=1)
|
| 542 |
+
smoothed_mask = ndimage.binary_closing(smoothed_mask, iterations=1)
|
| 543 |
+
# Light dilation to smooth edges
|
| 544 |
+
smoothed_mask = ndimage.binary_dilation(smoothed_mask, iterations=1)
|
| 545 |
+
except Exception:
|
| 546 |
+
smoothed_mask = category_mask
|
| 547 |
+
|
| 548 |
+
# Remove very small components (size threshold by category)
|
| 549 |
+
try:
|
| 550 |
+
from scipy import ndimage
|
| 551 |
+
labeled, num = ndimage.label(smoothed_mask)
|
| 552 |
+
sizes = np.bincount(labeled.ravel())
|
| 553 |
+
# Heavier categories can retain smaller features; light should be larger
|
| 554 |
+
min_pixels = {1: 80, 2: 50, 3: 25}.get(category_id, 40)
|
| 555 |
+
remove = sizes < min_pixels
|
| 556 |
+
remove_idx = np.nonzero(remove)[0]
|
| 557 |
+
if len(remove_idx) > 0:
|
| 558 |
+
smoothed_mask[np.isin(labeled, remove_idx)] = False
|
| 559 |
+
except Exception:
|
| 560 |
+
pass
|
| 561 |
|
| 562 |
# Find contours with simpler algorithm
|
| 563 |
try:
|
|
|
|
| 571 |
if len(contour) < 4: # Need at least 4 points for polygon
|
| 572 |
continue
|
| 573 |
|
| 574 |
+
# Simplify contour for speed while retaining shape
|
| 575 |
+
step = 2 if category_name == 'heavy' else 3
|
| 576 |
simplified_contour = contour[::step]
|
| 577 |
|
| 578 |
# Convert pixel coordinates to lat/lon
|
|
|
|
| 1614 |
# Try contour plotting
|
| 1615 |
try:
|
| 1616 |
opacity = 0.6 if polygons else 1.0 # Reduce opacity if polygons are shown
|
| 1617 |
+
# Use 2D z with 1D axes for rectilinear grids (avoids jagged artifacts)
|
| 1618 |
+
if lat2d.ndim == 2 and lon2d.ndim == 2:
|
| 1619 |
+
x_axis = lon2d[0, :]
|
| 1620 |
+
y_axis = lat2d[:, 0]
|
| 1621 |
+
else:
|
| 1622 |
+
x_axis = lon2d
|
| 1623 |
+
y_axis = lat2d
|
| 1624 |
fig.add_trace(go.Contour(
|
| 1625 |
+
x=x_axis,
|
| 1626 |
+
y=y_axis,
|
| 1627 |
+
z=z2d,
|
| 1628 |
colorscale=colorscale,
|
| 1629 |
+
contours=dict(coloring='heatmap'),
|
| 1630 |
showscale=True,
|
| 1631 |
opacity=opacity,
|
| 1632 |
colorbar=dict(
|
|
|
|
| 2473 |
""")
|
| 2474 |
|
| 2475 |
if __name__ == "__main__":
|
| 2476 |
+
app.launch(server_name="0.0.0.0", server_port=7860)
|