Spaces:
Build error
Build error
ok
Browse files- .DS_Store +0 -0
- README.md +65 -0
- app.py +32 -0
- apps/__init__.py +1 -0
- apps/canada_radar_gradio.py +166 -0
- pydardraw.egg-info/PKG-INFO +78 -0
- pydardraw.egg-info/SOURCES.txt +20 -0
- pydardraw.egg-info/dependency_links.txt +1 -0
- pydardraw.egg-info/not-zip-safe +1 -0
- pydardraw.egg-info/requires.txt +8 -0
- pydardraw.egg-info/top_level.txt +1 -0
- pydardraw/.DS_Store +0 -0
- pydardraw/__init__.py +8 -0
- pydardraw/__pycache__/__init__.cpython-312.pyc +0 -0
- pydardraw/colormaps/.DS_Store +0 -0
- pydardraw/colormaps/__pycache__/base.cpython-312.pyc +0 -0
- pydardraw/colormaps/__pycache__/canada_msc.cpython-312.pyc +0 -0
- pydardraw/colormaps/__pycache__/derive.cpython-312.pyc +0 -0
- pydardraw/colormaps/__pycache__/gradient.cpython-312.pyc +0 -0
- pydardraw/colormaps/__pycache__/nws_us.cpython-312.pyc +0 -0
- pydardraw/colormaps/base.py +123 -0
- pydardraw/colormaps/canada_msc.py +27 -0
- pydardraw/colormaps/derive.py +96 -0
- pydardraw/colormaps/gradient.py +177 -0
- pydardraw/colormaps/io.py +30 -0
- pydardraw/colormaps/nws_us.py +27 -0
- pydardraw/processing/__pycache__/components.cpython-312.pyc +0 -0
- pydardraw/processing/__pycache__/convert.cpython-312.pyc +0 -0
- pydardraw/processing/components.py +114 -0
- pydardraw/processing/convert.py +91 -0
- pydardraw/sources/__pycache__/msc_colormap.cpython-312.pyc +0 -0
- pydardraw/sources/__pycache__/msc_geomet.cpython-312.pyc +0 -0
- pydardraw/sources/msc_colormap.py +71 -0
- pydardraw/sources/msc_geomet.py +69 -0
- pydardraw/viz/__pycache__/folium_overlay.cpython-312.pyc +0 -0
- pydardraw/viz/folium_overlay.py +60 -0
- pyproject.toml +33 -0
- requirements.txt +9 -0
- runtime.txt +1 -0
- scripts/recolor_cli.py +33 -0
- scripts/run_demo.sh +89 -0
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
README.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Pydar Draw
|
| 3 |
emoji: 🏆
|
|
@@ -12,3 +13,67 @@ short_description: recolor radar feeds
|
|
| 12 |
---
|
| 13 |
|
| 14 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<<<<<<< HEAD
|
| 2 |
---
|
| 3 |
title: Pydar Draw
|
| 4 |
emoji: 🏆
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 16 |
+
=======
|
| 17 |
+
PydarDraw: Radar dBZ Quantization and Recoloring
|
| 18 |
+
|
| 19 |
+
Overview
|
| 20 |
+
- Fetch radar composite imagery from government WMS (e.g., MSC GeoMet).
|
| 21 |
+
- Quantize pixel colors to source-layer dBZ using a configurable color table, with optional fractional dBZ (xx.xx) estimates from anti-aliased pixels.
|
| 22 |
+
- Identify contiguous colored blocks (connected components) per dBZ.
|
| 23 |
+
- Recolor the image into another dBZ color table (e.g., US NWS).
|
| 24 |
+
|
| 25 |
+
Status
|
| 26 |
+
- Default color tables for US NWS reflectivity and a pragmatic Canadian MSC reflectivity table are included. You may override with your own JSON or derive from a provided legend image.
|
| 27 |
+
- No external services are required to run quantization/recoloring on a local image. Fetching from WMS requires network.
|
| 28 |
+
|
| 29 |
+
Quick Start
|
| 30 |
+
1) Recolor a local PNG using defaults (fractional dBZ optional):
|
| 31 |
+
```python
|
| 32 |
+
from PIL import Image
|
| 33 |
+
import numpy as np
|
| 34 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 35 |
+
from pydardraw.colormaps.canada_msc import CANADA_MSC_REF
|
| 36 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 37 |
+
|
| 38 |
+
img = Image.open("input_radar.png").convert("RGBA")
|
| 39 |
+
dbz, alpha = image_to_dbz(img, CANADA_MSC_REF, fractional=True) # set False for discrete
|
| 40 |
+
recolored = dbz_to_image(dbz, alpha, NWS_REF)
|
| 41 |
+
recolored.save("recolored_nws.png")
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
2) Connected components and pixel resolution:
|
| 45 |
+
```python
|
| 46 |
+
from pydardraw.processing.components import label_components
|
| 47 |
+
labels, stats = label_components(dbz, min_dbz=20)
|
| 48 |
+
# stats contains component sizes in pixels and km^2 if you supply bbox
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3) Fetch a Canada composite via MSC GeoMet WMS and recolor (exact legend-derived palette):
|
| 52 |
+
```python
|
| 53 |
+
from pydardraw.sources.msc_geomet import fetch_wms_png
|
| 54 |
+
from pydardraw.sources.msc_colormap import get_msc_reflectivity_colormap
|
| 55 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 56 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 57 |
+
|
| 58 |
+
img = fetch_wms_png(
|
| 59 |
+
layer="RADAR_1KM_RDBR",
|
| 60 |
+
bbox=(-141, 41.6751, -52.6194, 83.1139), # lon_min, lat_min, lon_max, lat_max
|
| 61 |
+
size=(1024, 768),
|
| 62 |
+
)
|
| 63 |
+
src_map = get_msc_reflectivity_colormap("RADAR_1KM_RDBR", derive_from_legend=True)
|
| 64 |
+
dbz, alpha = image_to_dbz(img, src_map, fractional=True)
|
| 65 |
+
recolored = dbz_to_image(dbz, alpha, NWS_REF)
|
| 66 |
+
recolored.save("canada_recolored.png")
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Color Tables
|
| 70 |
+
- US NWS reflectivity colors follow a commonly used palette for dBZ steps.
|
| 71 |
+
- Canadian MSC reflectivity colors are provided as a practical approximation; you can replace with your authoritative mapping (see `pydardraw/colormaps/canada_msc.py`).
|
| 72 |
+
|
| 73 |
+
Folium Overlay
|
| 74 |
+
- Create a Folium map with a recolored overlay and the original WMS layer for comparison using `pydardraw.viz.folium_overlay.make_map_with_overlay`.
|
| 75 |
+
|
| 76 |
+
Notes
|
| 77 |
+
- Transparent pixels are treated as no-data (NaN) in the dBZ array and preserved in alpha.
|
| 78 |
+
- Distance/resolution: Use the known WMS bbox and image size to compute km per pixel with a spherical earth approximation.
|
| 79 |
+
>>>>>>> eaf9109 (Add Gradio app for MSC→NWS recolor)
|
app.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio entrypoint for Hugging Face Spaces.
|
| 3 |
+
|
| 4 |
+
This wraps the existing demo in apps/canada_radar_gradio.py so Spaces can
|
| 5 |
+
discover `app.py` at the repo root.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
from apps.canada_radar_gradio import demo as _demo
|
| 13 |
+
|
| 14 |
+
# Expose as `demo` for Spaces discovery
|
| 15 |
+
demo = _demo
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
# Allow local run: `python app.py`
|
| 20 |
+
launch_kwargs = dict(server_name="0.0.0.0", debug=True)
|
| 21 |
+
port = os.getenv("GRADIO_SERVER_PORT")
|
| 22 |
+
if port:
|
| 23 |
+
try:
|
| 24 |
+
p = int(port)
|
| 25 |
+
if p > 0:
|
| 26 |
+
launch_kwargs["server_port"] = p
|
| 27 |
+
except Exception:
|
| 28 |
+
pass
|
| 29 |
+
share = os.getenv("GRADIO_SHARE", "0").lower() in {"1", "true", "yes"}
|
| 30 |
+
launch_kwargs["share"] = share
|
| 31 |
+
demo.launch(**launch_kwargs)
|
| 32 |
+
|
apps/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Mark apps as a package for imports
|
apps/canada_radar_gradio.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from PIL import Image
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from pydardraw.sources.msc_geomet import fetch_wms_png
|
| 6 |
+
from pydardraw.sources.msc_colormap import get_msc_reflectivity_colormap, get_msc_reflectivity_gradient_map
|
| 7 |
+
from pydardraw.colormaps.canada_msc import CANADA_MSC_REF
|
| 8 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 9 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 10 |
+
from pydardraw.processing.components import estimate_pixel_resolution, label_components, component_areas_km2
|
| 11 |
+
from pydardraw.viz.folium_overlay import make_map_with_overlay
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CANADA_BBOX = (-141.0, 41.6751, -52.6194, 83.1139) # lon_min, lat_min, lon_max, lat_max
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def fetch_and_recolor(
|
| 18 |
+
size_w: int,
|
| 19 |
+
size_h: int,
|
| 20 |
+
layer: str,
|
| 21 |
+
min_dbz: float = 20.0,
|
| 22 |
+
derive_palette: bool = True,
|
| 23 |
+
overlay_opacity: float = 0.8,
|
| 24 |
+
fractional_dbz: bool = True,
|
| 25 |
+
use_gradient_mapping: bool = True,
|
| 26 |
+
grad_min: float = 5.0,
|
| 27 |
+
grad_max: float = 75.0,
|
| 28 |
+
grad_orientation: str = "vertical",
|
| 29 |
+
grad_low_at_bottom: bool = True,
|
| 30 |
+
grad_stride: int = 1,
|
| 31 |
+
grad_manual_crop: bool = False,
|
| 32 |
+
crop_x0: int = 0,
|
| 33 |
+
crop_y0: int = 0,
|
| 34 |
+
crop_x1: int = 0,
|
| 35 |
+
crop_y1: int = 0,
|
| 36 |
+
):
|
| 37 |
+
try:
|
| 38 |
+
img = fetch_wms_png(layer=layer, bbox=CANADA_BBOX, size=(size_w, size_h))
|
| 39 |
+
except Exception as e:
|
| 40 |
+
return None, None, f"Fetch error: {e}", ""
|
| 41 |
+
|
| 42 |
+
# Choose mapping: gradient (pixel-by-pixel) or discrete stops
|
| 43 |
+
if use_gradient_mapping:
|
| 44 |
+
manual_bbox = (int(crop_x0), int(crop_y0), int(crop_x1), int(crop_y1)) if grad_manual_crop else None
|
| 45 |
+
grad_map = get_msc_reflectivity_gradient_map(
|
| 46 |
+
layer=layer,
|
| 47 |
+
min_dbz=grad_min,
|
| 48 |
+
max_dbz=grad_max,
|
| 49 |
+
orientation=grad_orientation,
|
| 50 |
+
low_at_bottom=grad_low_at_bottom,
|
| 51 |
+
sample_stride=int(grad_stride),
|
| 52 |
+
manual_bbox=manual_bbox,
|
| 53 |
+
)
|
| 54 |
+
# Use gradient map's fractional evaluator for precise decimals
|
| 55 |
+
dbz, alpha = image_to_dbz(img, grad_map, fractional=True)
|
| 56 |
+
else:
|
| 57 |
+
# Build exact palette (derived) if requested; fallback to built-in
|
| 58 |
+
src_map = get_msc_reflectivity_colormap(layer, derive_from_legend=derive_palette)
|
| 59 |
+
dbz, alpha = image_to_dbz(img, src_map, fractional=fractional_dbz)
|
| 60 |
+
recolored = dbz_to_image(dbz, alpha, NWS_REF)
|
| 61 |
+
|
| 62 |
+
res = estimate_pixel_resolution(CANADA_BBOX, (size_w, size_h))
|
| 63 |
+
labels, stats = label_components(dbz, min_dbz=float(min_dbz))
|
| 64 |
+
areas = component_areas_km2(labels, res)
|
| 65 |
+
|
| 66 |
+
# Summary text
|
| 67 |
+
comp_count = len(stats)
|
| 68 |
+
total_km2 = sum(areas.values())
|
| 69 |
+
# Compute basic stats (mean/median dBZ over valid)
|
| 70 |
+
valid = ~np.isnan(dbz)
|
| 71 |
+
mean_dbz = float(np.nanmean(dbz)) if np.any(valid) else float("nan")
|
| 72 |
+
median_dbz = float(np.nanmedian(dbz)) if np.any(valid) else float("nan")
|
| 73 |
+
summary = (
|
| 74 |
+
f"Resolution: {res.km_per_px_x:.2f} km/px (x), {res.km_per_px_y:.2f} km/px (y)\n"
|
| 75 |
+
f"Components (>= {min_dbz:.2f} dBZ): {comp_count}\n"
|
| 76 |
+
f"Total masked area: {total_km2:.0f} km^2\n"
|
| 77 |
+
f"Mean dBZ: {mean_dbz:.2f} Median dBZ: {median_dbz:.2f}"
|
| 78 |
+
)
|
| 79 |
+
# Also produce a Folium overlay for the recolored image
|
| 80 |
+
sw = (CANADA_BBOX[1], CANADA_BBOX[0])
|
| 81 |
+
ne = (CANADA_BBOX[3], CANADA_BBOX[2])
|
| 82 |
+
fmap = make_map_with_overlay(recolored, bounds=(sw, ne), opacity=overlay_opacity)
|
| 83 |
+
fmap_html = fmap._repr_html_()
|
| 84 |
+
|
| 85 |
+
return img, recolored, fmap_html, summary
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
with gr.Blocks(title="Canada Radar Recolor → US NWS") as demo:
|
| 89 |
+
gr.Markdown("# 🇨🇦→🇺🇸 Radar Recolor (MSC → NWS)")
|
| 90 |
+
gr.Markdown("Fetch MSC composite, quantize to dBZ, and recolor to NWS scale.")
|
| 91 |
+
with gr.Row():
|
| 92 |
+
with gr.Column(scale=1):
|
| 93 |
+
layer = gr.Dropdown(
|
| 94 |
+
choices=["RADAR_1KM_RDBR", "RADAR_1KM_RRAI", "RADAR_1KM_RSNO"],
|
| 95 |
+
value="RADAR_1KM_RDBR",
|
| 96 |
+
label="WMS Layer",
|
| 97 |
+
)
|
| 98 |
+
size_w = gr.Slider(256, 2048, value=1024, step=64, label="Width (px)")
|
| 99 |
+
size_h = gr.Slider(256, 2048, value=768, step=64, label="Height (px)")
|
| 100 |
+
min_dbz = gr.Slider(0, 75, value=20.0, step=0.25, label="Min dBZ for components")
|
| 101 |
+
derive_palette = gr.Checkbox(value=True, label="Derive MSC palette from legend (exact)")
|
| 102 |
+
overlay_opacity = gr.Slider(0.1, 1.0, value=0.8, step=0.05, label="Overlay opacity")
|
| 103 |
+
fractional_dbz = gr.Checkbox(value=True, label="Estimate fractional dBZ (xx.xx)")
|
| 104 |
+
use_gradient_mapping = gr.Checkbox(value=True, label="Use legend gradient mapping (pixel-by-pixel)")
|
| 105 |
+
grad_min = gr.Number(value=5.0, label="Legend min dBZ")
|
| 106 |
+
grad_max = gr.Number(value=75.0, label="Legend max dBZ")
|
| 107 |
+
grad_orientation = gr.Dropdown(["vertical", "horizontal"], value="vertical", label="Legend orientation")
|
| 108 |
+
grad_low_at_bottom = gr.Checkbox(value=True, label="Low at bottom / left")
|
| 109 |
+
grad_stride = gr.Slider(1, 10, value=1, step=1, label="Legend sample stride (px)")
|
| 110 |
+
grad_manual_crop = gr.Checkbox(value=False, label="Manual legend crop (px)")
|
| 111 |
+
with gr.Row():
|
| 112 |
+
crop_x0 = gr.Number(value=0, label="crop_x0 (left)")
|
| 113 |
+
crop_y0 = gr.Number(value=0, label="crop_y0 (top)")
|
| 114 |
+
crop_x1 = gr.Number(value=0, label="crop_x1 (right)")
|
| 115 |
+
crop_y1 = gr.Number(value=0, label="crop_y1 (bottom)")
|
| 116 |
+
btn = gr.Button("Fetch + Recolor", variant="primary")
|
| 117 |
+
|
| 118 |
+
summary = gr.Textbox(label="Summary", interactive=False)
|
| 119 |
+
|
| 120 |
+
with gr.Column(scale=2):
|
| 121 |
+
orig = gr.Image(label="Original (MSC)", interactive=False)
|
| 122 |
+
out = gr.Image(label="Recolored (NWS)", interactive=False)
|
| 123 |
+
fmap = gr.HTML(label="Folium Map with Recolored Overlay")
|
| 124 |
+
|
| 125 |
+
btn.click(
|
| 126 |
+
fetch_and_recolor,
|
| 127 |
+
inputs=[
|
| 128 |
+
size_w,
|
| 129 |
+
size_h,
|
| 130 |
+
layer,
|
| 131 |
+
min_dbz,
|
| 132 |
+
derive_palette,
|
| 133 |
+
overlay_opacity,
|
| 134 |
+
fractional_dbz,
|
| 135 |
+
use_gradient_mapping,
|
| 136 |
+
grad_min,
|
| 137 |
+
grad_max,
|
| 138 |
+
grad_orientation,
|
| 139 |
+
grad_low_at_bottom,
|
| 140 |
+
grad_stride,
|
| 141 |
+
grad_manual_crop,
|
| 142 |
+
crop_x0,
|
| 143 |
+
crop_y0,
|
| 144 |
+
crop_x1,
|
| 145 |
+
crop_y1,
|
| 146 |
+
],
|
| 147 |
+
outputs=[orig, out, fmap, summary],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
import os
|
| 153 |
+
launch_kwargs = dict(server_name="0.0.0.0", debug=True)
|
| 154 |
+
# Optional port override
|
| 155 |
+
port = os.getenv("GRADIO_SERVER_PORT")
|
| 156 |
+
if port:
|
| 157 |
+
try:
|
| 158 |
+
p = int(port)
|
| 159 |
+
if p > 0:
|
| 160 |
+
launch_kwargs["server_port"] = p
|
| 161 |
+
except Exception:
|
| 162 |
+
pass
|
| 163 |
+
# Optional share flag
|
| 164 |
+
share = os.getenv("GRADIO_SHARE", "0").lower() in {"1", "true", "yes"}
|
| 165 |
+
launch_kwargs["share"] = share
|
| 166 |
+
demo.launch(**launch_kwargs)
|
pydardraw.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: pydardraw
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Radar image processing: fetch, dBZ quantization, recoloring
|
| 5 |
+
Author: Your Name
|
| 6 |
+
Project-URL: Homepage, https://example.com
|
| 7 |
+
Requires-Python: >=3.9
|
| 8 |
+
Description-Content-Type: text/markdown
|
| 9 |
+
Requires-Dist: numpy>=1.23
|
| 10 |
+
Requires-Dist: Pillow>=9.0
|
| 11 |
+
Requires-Dist: requests>=2.31
|
| 12 |
+
Requires-Dist: gradio>=4.0
|
| 13 |
+
Requires-Dist: folium>=0.14
|
| 14 |
+
Provides-Extra: opencv
|
| 15 |
+
Requires-Dist: opencv-python>=4.7; extra == "opencv"
|
| 16 |
+
|
| 17 |
+
PydarDraw: Radar dBZ Quantization and Recoloring
|
| 18 |
+
|
| 19 |
+
Overview
|
| 20 |
+
- Fetch radar composite imagery from government WMS (e.g., MSC GeoMet).
|
| 21 |
+
- Quantize pixel colors to source-layer dBZ using a configurable color table, with optional fractional dBZ (xx.xx) estimates from anti-aliased pixels.
|
| 22 |
+
- Identify contiguous colored blocks (connected components) per dBZ.
|
| 23 |
+
- Recolor the image into another dBZ color table (e.g., US NWS).
|
| 24 |
+
|
| 25 |
+
Status
|
| 26 |
+
- Default color tables for US NWS reflectivity and a pragmatic Canadian MSC reflectivity table are included. You may override with your own JSON or derive from a provided legend image.
|
| 27 |
+
- No external services are required to run quantization/recoloring on a local image. Fetching from WMS requires network.
|
| 28 |
+
|
| 29 |
+
Quick Start
|
| 30 |
+
1) Recolor a local PNG using defaults (fractional dBZ optional):
|
| 31 |
+
```python
|
| 32 |
+
from PIL import Image
|
| 33 |
+
import numpy as np
|
| 34 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 35 |
+
from pydardraw.colormaps.canada_msc import CANADA_MSC_REF
|
| 36 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 37 |
+
|
| 38 |
+
img = Image.open("input_radar.png").convert("RGBA")
|
| 39 |
+
dbz, alpha = image_to_dbz(img, CANADA_MSC_REF, fractional=True) # set False for discrete
|
| 40 |
+
recolored = dbz_to_image(dbz, alpha, NWS_REF)
|
| 41 |
+
recolored.save("recolored_nws.png")
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
2) Connected components and pixel resolution:
|
| 45 |
+
```python
|
| 46 |
+
from pydardraw.processing.components import label_components
|
| 47 |
+
labels, stats = label_components(dbz, min_dbz=20)
|
| 48 |
+
# stats contains component sizes in pixels and km^2 if you supply bbox
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3) Fetch a Canada composite via MSC GeoMet WMS and recolor (exact legend-derived palette):
|
| 52 |
+
```python
|
| 53 |
+
from pydardraw.sources.msc_geomet import fetch_wms_png
|
| 54 |
+
from pydardraw.sources.msc_colormap import get_msc_reflectivity_colormap
|
| 55 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 56 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 57 |
+
|
| 58 |
+
img = fetch_wms_png(
|
| 59 |
+
layer="RADAR_1KM_RDBR",
|
| 60 |
+
bbox=(-141, 41.6751, -52.6194, 83.1139), # lon_min, lat_min, lon_max, lat_max
|
| 61 |
+
size=(1024, 768),
|
| 62 |
+
)
|
| 63 |
+
src_map = get_msc_reflectivity_colormap("RADAR_1KM_RDBR", derive_from_legend=True)
|
| 64 |
+
dbz, alpha = image_to_dbz(img, src_map, fractional=True)
|
| 65 |
+
recolored = dbz_to_image(dbz, alpha, NWS_REF)
|
| 66 |
+
recolored.save("canada_recolored.png")
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Color Tables
|
| 70 |
+
- US NWS reflectivity colors follow a commonly used palette for dBZ steps.
|
| 71 |
+
- Canadian MSC reflectivity colors are provided as a practical approximation; you can replace with your authoritative mapping (see `pydardraw/colormaps/canada_msc.py`).
|
| 72 |
+
|
| 73 |
+
Folium Overlay
|
| 74 |
+
- Create a Folium map with a recolored overlay and the original WMS layer for comparison using `pydardraw.viz.folium_overlay.make_map_with_overlay`.
|
| 75 |
+
|
| 76 |
+
Notes
|
| 77 |
+
- Transparent pixels are treated as no-data (NaN) in the dBZ array and preserved in alpha.
|
| 78 |
+
- Distance/resolution: Use the known WMS bbox and image size to compute km per pixel with a spherical earth approximation.
|
pydardraw.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
pydardraw/__init__.py
|
| 4 |
+
pydardraw.egg-info/PKG-INFO
|
| 5 |
+
pydardraw.egg-info/SOURCES.txt
|
| 6 |
+
pydardraw.egg-info/dependency_links.txt
|
| 7 |
+
pydardraw.egg-info/not-zip-safe
|
| 8 |
+
pydardraw.egg-info/requires.txt
|
| 9 |
+
pydardraw.egg-info/top_level.txt
|
| 10 |
+
pydardraw/colormaps/base.py
|
| 11 |
+
pydardraw/colormaps/canada_msc.py
|
| 12 |
+
pydardraw/colormaps/derive.py
|
| 13 |
+
pydardraw/colormaps/gradient.py
|
| 14 |
+
pydardraw/colormaps/io.py
|
| 15 |
+
pydardraw/colormaps/nws_us.py
|
| 16 |
+
pydardraw/processing/components.py
|
| 17 |
+
pydardraw/processing/convert.py
|
| 18 |
+
pydardraw/sources/msc_colormap.py
|
| 19 |
+
pydardraw/sources/msc_geomet.py
|
| 20 |
+
pydardraw/viz/folium_overlay.py
|
pydardraw.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
pydardraw.egg-info/not-zip-safe
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
pydardraw.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy>=1.23
|
| 2 |
+
Pillow>=9.0
|
| 3 |
+
requests>=2.31
|
| 4 |
+
gradio>=4.0
|
| 5 |
+
folium>=0.14
|
| 6 |
+
|
| 7 |
+
[opencv]
|
| 8 |
+
opencv-python>=4.7
|
pydardraw.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pydardraw
|
pydardraw/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
pydardraw/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__all__ = [
|
| 2 |
+
"colormaps",
|
| 3 |
+
"processing",
|
| 4 |
+
"sources",
|
| 5 |
+
]
|
| 6 |
+
|
| 7 |
+
__version__ = "0.1.0"
|
| 8 |
+
|
pydardraw/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (237 Bytes). View file
|
|
|
pydardraw/colormaps/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
pydardraw/colormaps/__pycache__/base.cpython-312.pyc
ADDED
|
Binary file (8.27 kB). View file
|
|
|
pydardraw/colormaps/__pycache__/canada_msc.cpython-312.pyc
ADDED
|
Binary file (1.13 kB). View file
|
|
|
pydardraw/colormaps/__pycache__/derive.cpython-312.pyc
ADDED
|
Binary file (5.03 kB). View file
|
|
|
pydardraw/colormaps/__pycache__/gradient.cpython-312.pyc
ADDED
|
Binary file (10.8 kB). View file
|
|
|
pydardraw/colormaps/__pycache__/nws_us.cpython-312.pyc
ADDED
|
Binary file (1.11 kB). View file
|
|
|
pydardraw/colormaps/base.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import cv2 # type: ignore
|
| 10 |
+
except Exception: # pragma: no cover - cv2 optional for LAB distances
|
| 11 |
+
cv2 = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
RGB = Tuple[int, int, int]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class ColorStop:
|
| 19 |
+
value: float # typically dBZ
|
| 20 |
+
color: RGB # (R, G, B)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ColorMap:
|
| 24 |
+
def __init__(self, stops: List[ColorStop], name: str = "") -> None:
|
| 25 |
+
# Ensure sorted by value
|
| 26 |
+
self._stops = sorted(stops, key=lambda s: s.value)
|
| 27 |
+
self.name = name or ""
|
| 28 |
+
|
| 29 |
+
self._values = np.array([s.value for s in self._stops], dtype=np.float32)
|
| 30 |
+
self._rgb = np.array([s.color for s in self._stops], dtype=np.uint8)
|
| 31 |
+
# Precompute LAB if possible for better perceptual distances
|
| 32 |
+
if cv2 is not None:
|
| 33 |
+
rgb_reshaped = self._rgb.reshape((-1, 1, 3))
|
| 34 |
+
self._lab = cv2.cvtColor(rgb_reshaped, cv2.COLOR_RGB2LAB).reshape((-1, 3)).astype(np.float32)
|
| 35 |
+
else:
|
| 36 |
+
self._lab = None
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def values(self) -> np.ndarray:
|
| 40 |
+
return self._values
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def rgb(self) -> np.ndarray:
|
| 44 |
+
return self._rgb
|
| 45 |
+
|
| 46 |
+
def nearest_value_for_color(self, color: RGB) -> float:
|
| 47 |
+
"""Return the dBZ (value) for the nearest palette color.
|
| 48 |
+
|
| 49 |
+
Uses CIE-LAB space if OpenCV available, otherwise RGB distance.
|
| 50 |
+
"""
|
| 51 |
+
c = np.array(color, dtype=np.uint8).reshape((1, 1, 3))
|
| 52 |
+
if self._lab is not None and cv2 is not None:
|
| 53 |
+
c_lab = cv2.cvtColor(c, cv2.COLOR_RGB2LAB).reshape((1, 3)).astype(np.float32)
|
| 54 |
+
dists = np.linalg.norm(self._lab - c_lab, axis=1)
|
| 55 |
+
else:
|
| 56 |
+
c_rgb = c.reshape((1, 3)).astype(np.float32)
|
| 57 |
+
dists = np.linalg.norm(self._rgb.astype(np.float32) - c_rgb, axis=1)
|
| 58 |
+
idx = int(np.argmin(dists))
|
| 59 |
+
return float(self._values[idx])
|
| 60 |
+
|
| 61 |
+
def color_for_value(self, value: float) -> RGB:
|
| 62 |
+
"""Return RGB for the nearest value stop."""
|
| 63 |
+
idx = int(np.argmin(np.abs(self._values - value)))
|
| 64 |
+
return tuple(int(x) for x in self._rgb[idx]) # type: ignore[return-value]
|
| 65 |
+
|
| 66 |
+
def fractional_value_for_color(self, color: RGB) -> float:
|
| 67 |
+
"""Estimate a fractional value (e.g., dBZ with decimals) by projecting
|
| 68 |
+
the color onto the nearest adjacent stop pair in LAB (preferred) or RGB.
|
| 69 |
+
|
| 70 |
+
If the color matches a stop exactly, returns the exact stop value.
|
| 71 |
+
"""
|
| 72 |
+
c = np.array(color, dtype=np.uint8).reshape((1, 1, 3))
|
| 73 |
+
if self._lab is not None and cv2 is not None:
|
| 74 |
+
c_lab = cv2.cvtColor(c, cv2.COLOR_RGB2LAB).reshape((3,)).astype(np.float32)
|
| 75 |
+
palette = self._lab
|
| 76 |
+
else:
|
| 77 |
+
c_lab = c.reshape((3,)).astype(np.float32)
|
| 78 |
+
palette = self._rgb.astype(np.float32)
|
| 79 |
+
|
| 80 |
+
# Exact match fast-path
|
| 81 |
+
if self._lab is not None and cv2 is not None:
|
| 82 |
+
dists = np.linalg.norm(palette - c_lab, axis=1)
|
| 83 |
+
else:
|
| 84 |
+
dists = np.linalg.norm(palette - c_lab, axis=1)
|
| 85 |
+
idx = int(np.argmin(dists))
|
| 86 |
+
if dists[idx] < 1e-3:
|
| 87 |
+
return float(self._values[idx])
|
| 88 |
+
|
| 89 |
+
# Choose adjacent neighbor to the nearest index
|
| 90 |
+
if idx == 0:
|
| 91 |
+
i, j = 0, 1
|
| 92 |
+
elif idx == len(self._values) - 1:
|
| 93 |
+
i, j = idx - 1, idx
|
| 94 |
+
else:
|
| 95 |
+
# Compare neighbors around idx
|
| 96 |
+
left = idx - 1
|
| 97 |
+
right = idx + 1
|
| 98 |
+
# Pick the adjacent whose projection distance is shortest
|
| 99 |
+
def proj_params(a, b):
|
| 100 |
+
va = palette[a]
|
| 101 |
+
vb = palette[b]
|
| 102 |
+
v = vb - va
|
| 103 |
+
t = float(np.dot(c_lab - va, v) / (np.dot(v, v) + 1e-12))
|
| 104 |
+
t = max(0.0, min(1.0, t))
|
| 105 |
+
p = va + t * v
|
| 106 |
+
err = float(np.linalg.norm(c_lab - p))
|
| 107 |
+
return t, err
|
| 108 |
+
|
| 109 |
+
t_l, err_l = proj_params(left, idx)
|
| 110 |
+
t_r, err_r = proj_params(idx, right)
|
| 111 |
+
if err_l <= err_r:
|
| 112 |
+
i, j, t = left, idx, t_l
|
| 113 |
+
else:
|
| 114 |
+
i, j, t = idx, right, t_r
|
| 115 |
+
return float(self._values[i] + t * (self._values[j] - self._values[i]))
|
| 116 |
+
|
| 117 |
+
# Fallback for edges using i, j chosen above
|
| 118 |
+
va = palette[i]
|
| 119 |
+
vb = palette[j]
|
| 120 |
+
v = vb - va
|
| 121 |
+
t = float(np.dot(c_lab - va, v) / (np.dot(v, v) + 1e-12))
|
| 122 |
+
t = max(0.0, min(1.0, t))
|
| 123 |
+
return float(self._values[i] + t * (self._values[j] - self._values[i]))
|
pydardraw/colormaps/canada_msc.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .base import ColorMap, ColorStop
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# Pragmatic approximation of Canadian MSC composite base reflectivity palette.
|
| 7 |
+
# Replace with authoritative mapping if available.
|
| 8 |
+
_MSC_STOPS = [
|
| 9 |
+
ColorStop(5, (0xB3, 0xFF, 0xFF)), # very light cyan
|
| 10 |
+
ColorStop(10, (0x80, 0xCC, 0xFF)), # light blue
|
| 11 |
+
ColorStop(15, (0x33, 0x99, 0xFF)), # blue
|
| 12 |
+
ColorStop(20, (0x33, 0xFF, 0x33)), # green
|
| 13 |
+
ColorStop(25, (0x00, 0xCC, 0x00)), # dark green
|
| 14 |
+
ColorStop(30, (0x00, 0x99, 0x00)), # darker green
|
| 15 |
+
ColorStop(35, (0xFF, 0xFF, 0x00)), # yellow
|
| 16 |
+
ColorStop(40, (0xFF, 0xCC, 0x00)), # amber
|
| 17 |
+
ColorStop(45, (0xFF, 0x99, 0x00)), # orange
|
| 18 |
+
ColorStop(50, (0xFF, 0x00, 0x00)), # red
|
| 19 |
+
ColorStop(55, (0xCC, 0x00, 0x00)), # dark red
|
| 20 |
+
ColorStop(60, (0x99, 0x00, 0x00)), # maroon
|
| 21 |
+
ColorStop(65, (0xFF, 0x00, 0xFF)), # magenta
|
| 22 |
+
ColorStop(70, (0x66, 0x00, 0xCC)), # purple
|
| 23 |
+
ColorStop(75, (0xFF, 0xFF, 0xFF)), # white
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
CANADA_MSC_REF = ColorMap(_MSC_STOPS, name="MSC Composite Reflectivity (dBZ)")
|
| 27 |
+
|
pydardraw/colormaps/derive.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Iterable, List, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import cv2 # type: ignore
|
| 11 |
+
except Exception: # pragma: no cover - optional
|
| 12 |
+
cv2 = None
|
| 13 |
+
|
| 14 |
+
from .base import ColorMap, ColorStop
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class LegendDeriveConfig:
|
| 19 |
+
values: List[float]
|
| 20 |
+
low_at_bottom: bool = True
|
| 21 |
+
kmeans_attempts: int = 3
|
| 22 |
+
text_suppress_gray_thresh: int = 24 # exclude near-gray text if desired
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def derive_colormap_from_legend(img: Image.Image, cfg: LegendDeriveConfig) -> ColorMap:
|
| 26 |
+
"""Derive a ColorMap from a legend image and provided dBZ values.
|
| 27 |
+
|
| 28 |
+
Assumes legend consists of K colored swatches (K=len(values)) arranged vertically,
|
| 29 |
+
optionally with text labels that are gray-ish and excluded by simple thresholding.
|
| 30 |
+
"""
|
| 31 |
+
if cv2 is None:
|
| 32 |
+
raise RuntimeError("OpenCV is required for legend derivation (k-means).")
|
| 33 |
+
|
| 34 |
+
if img.mode != "RGBA":
|
| 35 |
+
img = img.convert("RGBA")
|
| 36 |
+
arr = np.asarray(img, dtype=np.uint8)
|
| 37 |
+
h, w, _ = arr.shape
|
| 38 |
+
rgb = arr[:, :, :3]
|
| 39 |
+
alpha = arr[:, :, 3]
|
| 40 |
+
|
| 41 |
+
# Focus on non-transparent pixels
|
| 42 |
+
mask = alpha > 0
|
| 43 |
+
data = rgb[mask].reshape(-1, 3).astype(np.float32)
|
| 44 |
+
if data.size == 0:
|
| 45 |
+
raise ValueError("Legend image appears empty.")
|
| 46 |
+
|
| 47 |
+
# Suppress gray pixels (likely text/lines) by removing low chroma pixels
|
| 48 |
+
lab = cv2.cvtColor(data.reshape(-1, 1, 3).astype(np.uint8), cv2.COLOR_RGB2LAB).reshape(-1, 3)
|
| 49 |
+
chroma = np.linalg.norm(lab[:, 1:], axis=1)
|
| 50 |
+
keep = chroma > cfg.text_suppress_gray_thresh
|
| 51 |
+
data = data[keep]
|
| 52 |
+
|
| 53 |
+
K = len(cfg.values)
|
| 54 |
+
if data.shape[0] < K:
|
| 55 |
+
raise ValueError("Not enough legend pixels for k-means clustering.")
|
| 56 |
+
|
| 57 |
+
# KMeans clustering to find swatch colors
|
| 58 |
+
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 0.5)
|
| 59 |
+
_compactness, labels, centers = cv2.kmeans(
|
| 60 |
+
data,
|
| 61 |
+
K,
|
| 62 |
+
None,
|
| 63 |
+
criteria,
|
| 64 |
+
cfg.kmeans_attempts,
|
| 65 |
+
cv2.KMEANS_PP_CENTERS,
|
| 66 |
+
)
|
| 67 |
+
centers = centers.astype(np.uint8)
|
| 68 |
+
|
| 69 |
+
# Map cluster centers back to approximate y-position by matching center colors
|
| 70 |
+
# to pixels in the original mask and averaging their y.
|
| 71 |
+
# Build a simple nearest-color LUT for masked pixels
|
| 72 |
+
masked_coords = np.column_stack(np.nonzero(mask)) # (y, x)
|
| 73 |
+
masked_rgb = rgb[mask].reshape(-1, 3)
|
| 74 |
+
|
| 75 |
+
# Assign each pixel to nearest center
|
| 76 |
+
d2 = ((masked_rgb[:, None, :].astype(np.int32) - centers[None, :, :].astype(np.int32)) ** 2).sum(axis=2)
|
| 77 |
+
nearest = d2.argmin(axis=1)
|
| 78 |
+
|
| 79 |
+
avg_y = []
|
| 80 |
+
for k in range(K):
|
| 81 |
+
ys = masked_coords[nearest == k, 0]
|
| 82 |
+
if ys.size == 0:
|
| 83 |
+
avg_y.append(float('inf'))
|
| 84 |
+
else:
|
| 85 |
+
avg_y.append(float(ys.mean()))
|
| 86 |
+
|
| 87 |
+
# Order clusters by vertical position
|
| 88 |
+
order = np.argsort(avg_y)
|
| 89 |
+
if not cfg.low_at_bottom:
|
| 90 |
+
order = order[::-1]
|
| 91 |
+
|
| 92 |
+
ordered_centers = centers[order]
|
| 93 |
+
ordered_values = list(sorted(cfg.values))
|
| 94 |
+
stops = [ColorStop(val, tuple(map(int, c))) for val, c in zip(ordered_values, ordered_centers)]
|
| 95 |
+
return ColorMap(stops, name="Derived Legend")
|
| 96 |
+
|
pydardraw/colormaps/gradient.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Iterable, Optional, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
try:
|
| 10 |
+
import cv2 # type: ignore
|
| 11 |
+
except Exception: # pragma: no cover - optional
|
| 12 |
+
cv2 = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
RGB = Tuple[int, int, int]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class GradientConfig:
|
| 20 |
+
orientation: str = "vertical" # or "horizontal"
|
| 21 |
+
low_at_bottom: bool = True
|
| 22 |
+
sample_stride: int = 1 # sample every N rows/cols along the bar
|
| 23 |
+
saturation_thresh: int = 25 # HSV S threshold to ignore grayscale text
|
| 24 |
+
min_value: float = 5.0
|
| 25 |
+
max_value: float = 75.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ColorGradientMap:
|
| 29 |
+
"""Dense color→value mapping sampled from a legend gradient.
|
| 30 |
+
|
| 31 |
+
Provides fractional_value_for_color by k-NN interpolation in LAB (preferred) or RGB.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, colors_rgb: np.ndarray, values: np.ndarray, name: str = "LegendGradient") -> None:
|
| 35 |
+
assert colors_rgb.ndim == 2 and colors_rgb.shape[1] == 3
|
| 36 |
+
assert values.ndim == 1 and colors_rgb.shape[0] == values.shape[0]
|
| 37 |
+
self.name = name
|
| 38 |
+
self._rgb = colors_rgb.astype(np.uint8)
|
| 39 |
+
self._values = values.astype(np.float32)
|
| 40 |
+
if cv2 is not None:
|
| 41 |
+
self._lab = cv2.cvtColor(self._rgb.reshape(-1, 1, 3), cv2.COLOR_RGB2LAB).reshape(-1, 3).astype(np.float32)
|
| 42 |
+
else:
|
| 43 |
+
self._lab = None
|
| 44 |
+
|
| 45 |
+
def fractional_value_for_color(self, color: RGB, k: int = 3) -> float:
|
| 46 |
+
c = np.array(color, dtype=np.uint8).reshape(1, 1, 3)
|
| 47 |
+
if self._lab is not None and cv2 is not None:
|
| 48 |
+
c_lab = cv2.cvtColor(c, cv2.COLOR_RGB2LAB).reshape(1, 3).astype(np.float32)
|
| 49 |
+
d = np.linalg.norm(self._lab - c_lab, axis=1)
|
| 50 |
+
else:
|
| 51 |
+
c_rgb = c.reshape(1, 3).astype(np.float32)
|
| 52 |
+
d = np.linalg.norm(self._rgb.astype(np.float32) - c_rgb, axis=1)
|
| 53 |
+
# Exact match short-circuit
|
| 54 |
+
idx = int(np.argmin(d))
|
| 55 |
+
if float(d[idx]) < 1e-6:
|
| 56 |
+
return float(self._values[idx])
|
| 57 |
+
k = int(max(1, min(k, self._values.shape[0])))
|
| 58 |
+
nn = np.argpartition(d, kth=k - 1)[:k]
|
| 59 |
+
sel_d = d[nn]
|
| 60 |
+
sel_v = self._values[nn]
|
| 61 |
+
# Inverse-distance weighting
|
| 62 |
+
w = 1.0 / (sel_d.astype(np.float32) + 1e-6)
|
| 63 |
+
w /= w.sum()
|
| 64 |
+
return float((w * sel_v).sum())
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def _auto_detect_bar_mask(arr_rgba: np.ndarray, cfg: GradientConfig) -> np.ndarray:
|
| 68 |
+
# Heuristic: choose pixels with alpha>0 and saturation above threshold
|
| 69 |
+
alpha = arr_rgba[:, :, 3]
|
| 70 |
+
rgb = arr_rgba[:, :, :3]
|
| 71 |
+
if cv2 is not None:
|
| 72 |
+
hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
|
| 73 |
+
sat = hsv[:, :, 1]
|
| 74 |
+
else:
|
| 75 |
+
# Simple saturation proxy: max-min across RGB channels
|
| 76 |
+
sat = rgb.max(axis=2) - rgb.min(axis=2)
|
| 77 |
+
mask = (alpha > 0) & (sat >= cfg.saturation_thresh)
|
| 78 |
+
return mask
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _largest_component_bbox(mask: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
|
| 82 |
+
if cv2 is None:
|
| 83 |
+
ys, xs = np.nonzero(mask)
|
| 84 |
+
if ys.size == 0:
|
| 85 |
+
return None
|
| 86 |
+
return int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
| 87 |
+
num, labels = cv2.connectedComponents(mask.astype(np.uint8))
|
| 88 |
+
if num <= 1:
|
| 89 |
+
return None
|
| 90 |
+
best_area = 0
|
| 91 |
+
best_bbox = None
|
| 92 |
+
for lab in range(1, num):
|
| 93 |
+
ys, xs = np.where(labels == lab)
|
| 94 |
+
if ys.size == 0:
|
| 95 |
+
continue
|
| 96 |
+
x0, y0, x1, y1 = xs.min(), ys.min(), xs.max(), ys.max()
|
| 97 |
+
area = (x1 - x0 + 1) * (y1 - y0 + 1)
|
| 98 |
+
if area > best_area:
|
| 99 |
+
best_area = area
|
| 100 |
+
best_bbox = (x0, y0, x1, y1)
|
| 101 |
+
return best_bbox
|
| 102 |
+
|
| 103 |
+
@classmethod
|
| 104 |
+
def from_legend_image(
|
| 105 |
+
cls,
|
| 106 |
+
img: Image.Image,
|
| 107 |
+
cfg: GradientConfig,
|
| 108 |
+
manual_bbox: Optional[Tuple[int, int, int, int]] = None,
|
| 109 |
+
) -> "ColorGradientMap":
|
| 110 |
+
if img.mode != "RGBA":
|
| 111 |
+
img = img.convert("RGBA")
|
| 112 |
+
arr = np.asarray(img, dtype=np.uint8)
|
| 113 |
+
h, w, _ = arr.shape
|
| 114 |
+
if manual_bbox is not None:
|
| 115 |
+
x0, y0, x1, y1 = manual_bbox
|
| 116 |
+
# clamp
|
| 117 |
+
x0 = max(0, min(x0, w - 1))
|
| 118 |
+
x1 = max(0, min(x1, w - 1))
|
| 119 |
+
y0 = max(0, min(y0, h - 1))
|
| 120 |
+
y1 = max(0, min(y1, h - 1))
|
| 121 |
+
if x0 > x1:
|
| 122 |
+
x0, x1 = x1, x0
|
| 123 |
+
if y0 > y1:
|
| 124 |
+
y0, y1 = y1, y0
|
| 125 |
+
else:
|
| 126 |
+
mask = cls._auto_detect_bar_mask(arr, cfg)
|
| 127 |
+
bbox = cls._largest_component_bbox(mask)
|
| 128 |
+
if bbox is None:
|
| 129 |
+
# Fallback: rightmost quarter for vertical, bottom quarter for horizontal
|
| 130 |
+
if cfg.orientation == "vertical":
|
| 131 |
+
x0 = int(0.7 * w)
|
| 132 |
+
x1 = w - 1
|
| 133 |
+
y0, y1 = 0, h - 1
|
| 134 |
+
else:
|
| 135 |
+
y0 = int(0.7 * h)
|
| 136 |
+
y1 = h - 1
|
| 137 |
+
x0, x1 = 0, w - 1
|
| 138 |
+
else:
|
| 139 |
+
x0, y0, x1, y1 = bbox
|
| 140 |
+
# x0,y0,x1,y1 are now defined
|
| 141 |
+
|
| 142 |
+
rgb = arr[:, :, :3]
|
| 143 |
+
colors: list[RGB] = []
|
| 144 |
+
values: list[float] = []
|
| 145 |
+
|
| 146 |
+
if cfg.orientation == "vertical":
|
| 147 |
+
xs = slice(x0, x1 + 1)
|
| 148 |
+
for y in range(y0, y1 + 1, max(1, cfg.sample_stride)):
|
| 149 |
+
row = rgb[y, xs, :]
|
| 150 |
+
# Robust color for the row: median across width
|
| 151 |
+
med = np.median(row, axis=0).astype(np.uint8)
|
| 152 |
+
frac = (y - y0) / max(1, (y1 - y0))
|
| 153 |
+
if cfg.low_at_bottom:
|
| 154 |
+
val = cfg.max_value - frac * (cfg.max_value - cfg.min_value)
|
| 155 |
+
else:
|
| 156 |
+
val = cfg.min_value + frac * (cfg.max_value - cfg.min_value)
|
| 157 |
+
colors.append((int(med[0]), int(med[1]), int(med[2])))
|
| 158 |
+
values.append(float(val))
|
| 159 |
+
else:
|
| 160 |
+
ys = slice(y0, y1 + 1)
|
| 161 |
+
for x in range(x0, x1 + 1, max(1, cfg.sample_stride)):
|
| 162 |
+
col = rgb[ys, x, :]
|
| 163 |
+
med = np.median(col, axis=0).astype(np.uint8)
|
| 164 |
+
frac = (x - x0) / max(1, (x1 - x0))
|
| 165 |
+
if cfg.low_at_bottom:
|
| 166 |
+
val = cfg.min_value + frac * (cfg.max_value - cfg.min_value)
|
| 167 |
+
else:
|
| 168 |
+
val = cfg.max_value - frac * (cfg.max_value - cfg.min_value)
|
| 169 |
+
colors.append((int(med[0]), int(med[1]), int(med[2])))
|
| 170 |
+
values.append(float(val))
|
| 171 |
+
|
| 172 |
+
# Deduplicate colors to keep mapping tight
|
| 173 |
+
arr_colors = np.array(colors, dtype=np.uint8)
|
| 174 |
+
arr_values = np.array(values, dtype=np.float32)
|
| 175 |
+
uniq, idx = np.unique(arr_colors, axis=0, return_index=True)
|
| 176 |
+
uniq_values = arr_values[idx]
|
| 177 |
+
return cls(uniq, uniq_values, name="LegendGradient")
|
pydardraw/colormaps/io.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Iterable, List, Tuple
|
| 6 |
+
|
| 7 |
+
from .base import ColorMap, ColorStop
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _parse_hex_rgb(hex_str: str) -> Tuple[int, int, int]:
|
| 11 |
+
s = hex_str.strip().lstrip('#')
|
| 12 |
+
if len(s) == 6:
|
| 13 |
+
r = int(s[0:2], 16)
|
| 14 |
+
g = int(s[2:4], 16)
|
| 15 |
+
b = int(s[4:6], 16)
|
| 16 |
+
return (r, g, b)
|
| 17 |
+
raise ValueError(f"Invalid hex RGB: {hex_str}")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_colormap_from_json(path: Path | str, name: str | None = None) -> ColorMap:
|
| 21 |
+
"""Load a colormap JSON of the form [{"value": 5, "color": "#RRGGBB"}, ...]."""
|
| 22 |
+
p = Path(path)
|
| 23 |
+
data = json.loads(p.read_text())
|
| 24 |
+
stops: List[ColorStop] = []
|
| 25 |
+
for item in data:
|
| 26 |
+
val = float(item["value"]) # type: ignore[index]
|
| 27 |
+
col = item["color"] # type: ignore[index]
|
| 28 |
+
stops.append(ColorStop(val, _parse_hex_rgb(str(col))))
|
| 29 |
+
return ColorMap(stops, name=name or p.stem)
|
| 30 |
+
|
pydardraw/colormaps/nws_us.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .base import ColorMap, ColorStop
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# A commonly used US NWS reflectivity color table (approximate hex -> RGB)
|
| 7 |
+
# Steps at 5 dBZ from 5 to 75. You may customize as needed.
|
| 8 |
+
_NWS_STOPS = [
|
| 9 |
+
ColorStop(5, (0x04, 0xE9, 0xE7)), # cyan
|
| 10 |
+
ColorStop(10, (0x01, 0x9F, 0xF4)), # blue
|
| 11 |
+
ColorStop(15, (0x03, 0x00, 0xF4)), # deep blue
|
| 12 |
+
ColorStop(20, (0x02, 0xFD, 0x02)), # green
|
| 13 |
+
ColorStop(25, (0x01, 0xC5, 0x01)), # dark green
|
| 14 |
+
ColorStop(30, (0x00, 0x8E, 0x00)), # darker green
|
| 15 |
+
ColorStop(35, (0xFD, 0xF8, 0x02)), # yellow
|
| 16 |
+
ColorStop(40, (0xE5, 0xBC, 0x00)), # mustard
|
| 17 |
+
ColorStop(45, (0xFD, 0x95, 0x00)), # orange
|
| 18 |
+
ColorStop(50, (0xFD, 0x00, 0x00)), # red
|
| 19 |
+
ColorStop(55, (0xD4, 0x00, 0x00)), # dark red
|
| 20 |
+
ColorStop(60, (0xBC, 0x00, 0x00)), # darker red
|
| 21 |
+
ColorStop(65, (0xF8, 0x00, 0xFD)), # magenta
|
| 22 |
+
ColorStop(70, (0x98, 0x54, 0xC6)), # purple
|
| 23 |
+
ColorStop(75, (0xFD, 0xFD, 0xFD)), # white
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
NWS_REF = ColorMap(_NWS_STOPS, name="NWS Reflectivity (dBZ)")
|
| 27 |
+
|
pydardraw/processing/__pycache__/components.cpython-312.pyc
ADDED
|
Binary file (5.34 kB). View file
|
|
|
pydardraw/processing/__pycache__/convert.cpython-312.pyc
ADDED
|
Binary file (4.88 kB). View file
|
|
|
pydardraw/processing/components.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Dict, Optional, Tuple
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import cv2 # type: ignore
|
| 10 |
+
except Exception: # pragma: no cover - optional
|
| 11 |
+
cv2 = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
EARTH_RADIUS_KM = 6371.0088
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
| 18 |
+
import math
|
| 19 |
+
|
| 20 |
+
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
| 21 |
+
dphi = phi2 - phi1
|
| 22 |
+
dlambda = math.radians(lon2 - lon1)
|
| 23 |
+
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
| 24 |
+
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class PixelResolution:
|
| 29 |
+
km_per_px_x: float
|
| 30 |
+
km_per_px_y: float
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def estimate_pixel_resolution(
|
| 34 |
+
bbox: Tuple[float, float, float, float],
|
| 35 |
+
size: Tuple[int, int],
|
| 36 |
+
) -> PixelResolution:
|
| 37 |
+
"""Approximate km per pixel using bbox=(lon_min, lat_min, lon_max, lat_max) and image size=(w,h)."""
|
| 38 |
+
lon_min, lat_min, lon_max, lat_max = bbox
|
| 39 |
+
w, h = size
|
| 40 |
+
# Horizontal distance along center latitude
|
| 41 |
+
lat_c = (lat_min + lat_max) / 2.0
|
| 42 |
+
km_x = _haversine_km(lat_c, lon_min, lat_c, lon_max)
|
| 43 |
+
km_y = _haversine_km(lat_min, lon_min, lat_max, lon_min)
|
| 44 |
+
return PixelResolution(km_per_px_x=km_x / max(1, w), km_per_px_y=km_y / max(1, h))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def label_components(dbz: np.ndarray, min_dbz: Optional[float] = None) -> Tuple[np.ndarray, Dict[int, Dict[str, float]]]:
|
| 48 |
+
"""Label connected components of non-NaN (and optionally >= min_dbz) pixels.
|
| 49 |
+
|
| 50 |
+
Returns (labels, stats) where labels is an int32 array with 0 as background,
|
| 51 |
+
and stats is {label: {"pixel_count": int}}.
|
| 52 |
+
"""
|
| 53 |
+
mask = ~np.isnan(dbz)
|
| 54 |
+
if min_dbz is not None:
|
| 55 |
+
mask &= dbz >= float(min_dbz)
|
| 56 |
+
|
| 57 |
+
if cv2 is None:
|
| 58 |
+
# Fallback: 4-neighbor connected components via scipy-like approach (slow)
|
| 59 |
+
# Simple two-pass algorithm
|
| 60 |
+
h, w = dbz.shape
|
| 61 |
+
labels = np.zeros((h, w), dtype=np.int32)
|
| 62 |
+
current = 0
|
| 63 |
+
label_equiv = {}
|
| 64 |
+
for y in range(h):
|
| 65 |
+
for x in range(w):
|
| 66 |
+
if not mask[y, x]:
|
| 67 |
+
continue
|
| 68 |
+
neighbors = []
|
| 69 |
+
if x > 0 and labels[y, x - 1] > 0:
|
| 70 |
+
neighbors.append(labels[y, x - 1])
|
| 71 |
+
if y > 0 and labels[y - 1, x] > 0:
|
| 72 |
+
neighbors.append(labels[y - 1, x])
|
| 73 |
+
if not neighbors:
|
| 74 |
+
current += 1
|
| 75 |
+
labels[y, x] = current
|
| 76 |
+
else:
|
| 77 |
+
m = min(neighbors)
|
| 78 |
+
labels[y, x] = m
|
| 79 |
+
for n in neighbors:
|
| 80 |
+
if n != m:
|
| 81 |
+
label_equiv[n] = m
|
| 82 |
+
# Resolve equivalences
|
| 83 |
+
for y in range(h):
|
| 84 |
+
for x in range(w):
|
| 85 |
+
l = labels[y, x]
|
| 86 |
+
while l in label_equiv:
|
| 87 |
+
l = label_equiv[l]
|
| 88 |
+
labels[y, x] = l
|
| 89 |
+
else:
|
| 90 |
+
mask_u8 = (mask.astype(np.uint8) * 255)
|
| 91 |
+
num, labels = cv2.connectedComponents(mask_u8, connectivity=4)
|
| 92 |
+
# OpenCV labels background = 0, components 1..num-1
|
| 93 |
+
# Already good.
|
| 94 |
+
|
| 95 |
+
# Stats
|
| 96 |
+
stats: Dict[int, Dict[str, float]] = {}
|
| 97 |
+
unique, counts = np.unique(labels[labels > 0], return_counts=True)
|
| 98 |
+
for lab, cnt in zip(unique.tolist(), counts.tolist()):
|
| 99 |
+
stats[int(lab)] = {"pixel_count": float(cnt)}
|
| 100 |
+
return labels, stats
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def component_areas_km2(
|
| 104 |
+
labels: np.ndarray,
|
| 105 |
+
resolution: PixelResolution,
|
| 106 |
+
) -> Dict[int, float]:
|
| 107 |
+
"""Compute area in km^2 for each labeled component."""
|
| 108 |
+
areas: Dict[int, float] = {}
|
| 109 |
+
unique, counts = np.unique(labels[labels > 0], return_counts=True)
|
| 110 |
+
px_area = resolution.km_per_px_x * resolution.km_per_px_y
|
| 111 |
+
for lab, cnt in zip(unique.tolist(), counts.tolist()):
|
| 112 |
+
areas[int(lab)] = cnt * px_area
|
| 113 |
+
return areas
|
| 114 |
+
|
pydardraw/processing/convert.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
from ..colormaps.base import ColorMap
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _to_numpy_rgba(img: Image.Image) -> Tuple[np.ndarray, np.ndarray]:
|
| 12 |
+
if img.mode != "RGBA":
|
| 13 |
+
img = img.convert("RGBA")
|
| 14 |
+
arr = np.asarray(img, dtype=np.uint8)
|
| 15 |
+
rgb = arr[:, :, :3]
|
| 16 |
+
alpha = arr[:, :, 3]
|
| 17 |
+
return rgb, alpha
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _unique_color_map(values_rgb: np.ndarray, func) -> dict:
|
| 21 |
+
"""Map unique RGB colors to numeric values using the provided func(color)->float."""
|
| 22 |
+
lut: dict[tuple, float] = {}
|
| 23 |
+
for rgb in values_rgb:
|
| 24 |
+
key = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
| 25 |
+
if key not in lut:
|
| 26 |
+
lut[key] = float(func(key))
|
| 27 |
+
return lut
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _apply_lut_to_pixels(rgb: np.ndarray, alpha: np.ndarray, lut: dict) -> np.ndarray:
|
| 31 |
+
h, w, _ = rgb.shape
|
| 32 |
+
out = np.full((h, w), np.nan, dtype=np.float32)
|
| 33 |
+
flat_rgb = rgb.reshape(-1, 3)
|
| 34 |
+
flat_alpha = alpha.reshape(-1)
|
| 35 |
+
flat_out = out.reshape(-1)
|
| 36 |
+
for i in range(flat_rgb.shape[0]):
|
| 37 |
+
if flat_alpha[i] == 0:
|
| 38 |
+
continue
|
| 39 |
+
key = (int(flat_rgb[i, 0]), int(flat_rgb[i, 1]), int(flat_rgb[i, 2]))
|
| 40 |
+
val = lut.get(key)
|
| 41 |
+
if val is not None:
|
| 42 |
+
flat_out[i] = float(val)
|
| 43 |
+
return out
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def image_to_dbz(
|
| 47 |
+
img: Image.Image,
|
| 48 |
+
src_map: ColorMap,
|
| 49 |
+
fractional: bool = False,
|
| 50 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 51 |
+
"""Convert a colored radar image to a dBZ array using a source ColorMap.
|
| 52 |
+
|
| 53 |
+
- If fractional is False, snaps to nearest stop value exactly.
|
| 54 |
+
- If fractional is True, estimates a decimal value via LAB/RGB projection.
|
| 55 |
+
|
| 56 |
+
Returns (dbz_array, alpha_mask). Transparent pixels are NaN in dBZ.
|
| 57 |
+
"""
|
| 58 |
+
rgb, alpha = _to_numpy_rgba(img)
|
| 59 |
+
# Collect unique RGB colors present (non-transparent)
|
| 60 |
+
mask = alpha > 0
|
| 61 |
+
unique_rgb = np.unique(rgb[mask].reshape(-1, 3), axis=0) if np.any(mask) else np.zeros((0, 3), dtype=np.uint8)
|
| 62 |
+
|
| 63 |
+
# Build LUT
|
| 64 |
+
if fractional:
|
| 65 |
+
lut = _unique_color_map(unique_rgb, src_map.fractional_value_for_color)
|
| 66 |
+
else:
|
| 67 |
+
lut = _unique_color_map(unique_rgb, src_map.nearest_value_for_color)
|
| 68 |
+
|
| 69 |
+
# Apply LUT
|
| 70 |
+
dbz = _apply_lut_to_pixels(rgb, alpha, lut)
|
| 71 |
+
return dbz, alpha
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def dbz_to_image(dbz: np.ndarray, alpha: np.ndarray, dst_map: ColorMap) -> Image.Image:
|
| 75 |
+
"""Render a dBZ array (and alpha mask) into a colored RGBA image using dst_map."""
|
| 76 |
+
h, w = dbz.shape
|
| 77 |
+
rgb = np.zeros((h, w, 3), dtype=np.uint8)
|
| 78 |
+
|
| 79 |
+
# Map NaN to transparent
|
| 80 |
+
valid = ~np.isnan(dbz)
|
| 81 |
+
if np.any(valid):
|
| 82 |
+
# Vectorized nearest value mapping via LUT
|
| 83 |
+
vals = dbz[valid]
|
| 84 |
+
# Quantize to nearest stops
|
| 85 |
+
stops = dst_map.values
|
| 86 |
+
idx = np.abs(vals.reshape(-1, 1) - stops.reshape(1, -1)).argmin(axis=1)
|
| 87 |
+
colors = dst_map.rgb[idx]
|
| 88 |
+
rgb[valid] = colors
|
| 89 |
+
|
| 90 |
+
rgba = np.dstack([rgb, alpha.astype(np.uint8)])
|
| 91 |
+
return Image.fromarray(rgba, mode="RGBA")
|
pydardraw/sources/__pycache__/msc_colormap.cpython-312.pyc
ADDED
|
Binary file (2.86 kB). View file
|
|
|
pydardraw/sources/__pycache__/msc_geomet.cpython-312.pyc
ADDED
|
Binary file (2.73 kB). View file
|
|
|
pydardraw/sources/msc_colormap.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Optional, List
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from ..colormaps.base import ColorMap
|
| 8 |
+
from ..colormaps.derive import LegendDeriveConfig, derive_colormap_from_legend
|
| 9 |
+
from ..colormaps.canada_msc import CANADA_MSC_REF
|
| 10 |
+
from ..colormaps.gradient import ColorGradientMap, GradientConfig
|
| 11 |
+
from .msc_geomet import fetch_legend_png
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_msc_reflectivity_colormap(
|
| 15 |
+
layer: str = "RADAR_1KM_RDBR",
|
| 16 |
+
derive_from_legend: bool = True,
|
| 17 |
+
assumed_values: Optional[List[float]] = None,
|
| 18 |
+
) -> ColorMap:
|
| 19 |
+
"""Return a ColorMap for MSC reflectivity layers.
|
| 20 |
+
|
| 21 |
+
If derive_from_legend is True, fetches GetLegendGraphic and derives palette colors.
|
| 22 |
+
If no explicit values provided, assumes 5 dBZ steps [5..75] for K swatches.
|
| 23 |
+
Falls back to a built-in approximation if derivation fails.
|
| 24 |
+
"""
|
| 25 |
+
if not derive_from_legend:
|
| 26 |
+
return CANADA_MSC_REF
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
legend: Image.Image = fetch_legend_png(layer)
|
| 30 |
+
# If values are not supplied, guess K from legend clusters and use 5 dBZ steps
|
| 31 |
+
if assumed_values is None:
|
| 32 |
+
# Probe K by running k-means on legend and trying a few candidates.
|
| 33 |
+
# We do a two-pass: first use a typical K=15 (5..75), else try 14 or 16.
|
| 34 |
+
for K in (15, 14, 16):
|
| 35 |
+
vals = [5 + 5 * i for i in range(K)]
|
| 36 |
+
try:
|
| 37 |
+
cmap = derive_colormap_from_legend(legend, LegendDeriveConfig(values=vals))
|
| 38 |
+
return cmap
|
| 39 |
+
except Exception:
|
| 40 |
+
continue
|
| 41 |
+
# Final attempt with built-in fallback
|
| 42 |
+
return CANADA_MSC_REF
|
| 43 |
+
else:
|
| 44 |
+
cmap = derive_colormap_from_legend(legend, LegendDeriveConfig(values=assumed_values))
|
| 45 |
+
return cmap
|
| 46 |
+
except Exception:
|
| 47 |
+
return CANADA_MSC_REF
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_msc_reflectivity_gradient_map(
|
| 51 |
+
layer: str = "RADAR_1KM_RDBR",
|
| 52 |
+
min_dbz: float = 5.0,
|
| 53 |
+
max_dbz: float = 75.0,
|
| 54 |
+
orientation: str = "vertical",
|
| 55 |
+
low_at_bottom: bool = True,
|
| 56 |
+
sample_stride: int = 1,
|
| 57 |
+
manual_bbox: Optional[tuple[int, int, int, int]] = None,
|
| 58 |
+
) -> ColorGradientMap:
|
| 59 |
+
"""Fetch the legend and build a dense pixel-by-pixel color→dBZ map.
|
| 60 |
+
|
| 61 |
+
This enables highly accurate fractional dBZ estimation by nearest colors.
|
| 62 |
+
"""
|
| 63 |
+
legend: Image.Image = fetch_legend_png(layer)
|
| 64 |
+
cfg = GradientConfig(
|
| 65 |
+
orientation=orientation,
|
| 66 |
+
low_at_bottom=low_at_bottom,
|
| 67 |
+
sample_stride=sample_stride,
|
| 68 |
+
min_value=min_dbz,
|
| 69 |
+
max_value=max_dbz,
|
| 70 |
+
)
|
| 71 |
+
return ColorGradientMap.from_legend_image(legend, cfg, manual_bbox=manual_bbox)
|
pydardraw/sources/msc_geomet.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from typing import Optional, Tuple
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
WMS_URL = "https://geo.weather.gc.ca/geomet"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def fetch_wms_png(
|
| 14 |
+
layer: str,
|
| 15 |
+
bbox: Tuple[float, float, float, float], # (lon_min, lat_min, lon_max, lat_max) in EPSG:4326
|
| 16 |
+
size: Tuple[int, int], # (width, height)
|
| 17 |
+
time: Optional[str] = None, # ISO8601 or latest if None
|
| 18 |
+
transparent: bool = True,
|
| 19 |
+
fmt: str = "image/png",
|
| 20 |
+
wms_url: str = WMS_URL,
|
| 21 |
+
) -> Image.Image:
|
| 22 |
+
"""Fetch a single WMS GetMap image for the given bbox and size.
|
| 23 |
+
|
| 24 |
+
Note: Network must be available. Returns a Pillow Image in RGBA if PNG.
|
| 25 |
+
"""
|
| 26 |
+
params = {
|
| 27 |
+
"service": "WMS",
|
| 28 |
+
"request": "GetMap",
|
| 29 |
+
"version": "1.3.0",
|
| 30 |
+
"layers": layer,
|
| 31 |
+
"styles": "",
|
| 32 |
+
"format": fmt,
|
| 33 |
+
"transparent": str(transparent).lower(),
|
| 34 |
+
"crs": "EPSG:4326",
|
| 35 |
+
"bbox": f"{bbox[1]},{bbox[0]},{bbox[3]},{bbox[2]}", # lat_min,lon_min,lat_max,lon_max for 1.3.0 EPSG:4326
|
| 36 |
+
"width": str(size[0]),
|
| 37 |
+
"height": str(size[1]),
|
| 38 |
+
}
|
| 39 |
+
if time:
|
| 40 |
+
params["time"] = time
|
| 41 |
+
r = requests.get(wms_url, params=params, timeout=30)
|
| 42 |
+
r.raise_for_status()
|
| 43 |
+
img = Image.open(BytesIO(r.content))
|
| 44 |
+
if img.mode != "RGBA":
|
| 45 |
+
img = img.convert("RGBA")
|
| 46 |
+
return img
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def fetch_legend_png(
|
| 50 |
+
layer: str,
|
| 51 |
+
wms_url: str = WMS_URL,
|
| 52 |
+
scale: int = 1,
|
| 53 |
+
) -> Image.Image:
|
| 54 |
+
"""Fetch WMS GetLegendGraphic image if available (for custom color-table derivation)."""
|
| 55 |
+
params = {
|
| 56 |
+
"service": "WMS",
|
| 57 |
+
"request": "GetLegendGraphic",
|
| 58 |
+
"version": "1.3.0",
|
| 59 |
+
"layer": layer,
|
| 60 |
+
"format": "image/png",
|
| 61 |
+
"scale": str(scale),
|
| 62 |
+
}
|
| 63 |
+
r = requests.get(wms_url, params=params, timeout=30)
|
| 64 |
+
r.raise_for_status()
|
| 65 |
+
img = Image.open(BytesIO(r.content))
|
| 66 |
+
if img.mode != "RGBA":
|
| 67 |
+
img = img.convert("RGBA")
|
| 68 |
+
return img
|
| 69 |
+
|
pydardraw/viz/__pycache__/folium_overlay.cpython-312.pyc
ADDED
|
Binary file (2.7 kB). View file
|
|
|
pydardraw/viz/folium_overlay.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from typing import Tuple
|
| 6 |
+
|
| 7 |
+
import folium
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from folium.raster_layers import WmsTileLayer
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def image_to_data_url(img: Image.Image, fmt: str = "PNG") -> str:
|
| 13 |
+
buf = BytesIO()
|
| 14 |
+
img.save(buf, format=fmt)
|
| 15 |
+
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
| 16 |
+
return f"data:image/{fmt.lower()};base64,{b64}"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def make_map_with_overlay(
|
| 20 |
+
overlay: Image.Image,
|
| 21 |
+
bounds: Tuple[Tuple[float, float], Tuple[float, float]], # (southwest(lat,lon), northeast(lat,lon))
|
| 22 |
+
opacity: float = 0.8,
|
| 23 |
+
center: Tuple[float, float] | None = None,
|
| 24 |
+
zoom_start: int = 4,
|
| 25 |
+
add_raw_wms: bool = True,
|
| 26 |
+
wms_url: str = "https://geo.weather.gc.ca/geomet",
|
| 27 |
+
wms_layer: str = "RADAR_1KM_RDBR",
|
| 28 |
+
) -> folium.Map:
|
| 29 |
+
"""Return a Folium map with the given image overlay and bounds."""
|
| 30 |
+
if center is None:
|
| 31 |
+
lat = (bounds[0][0] + bounds[1][0]) / 2.0
|
| 32 |
+
lon = (bounds[0][1] + bounds[1][1]) / 2.0
|
| 33 |
+
center = (lat, lon)
|
| 34 |
+
|
| 35 |
+
m = folium.Map(location=center, zoom_start=zoom_start, tiles="OpenStreetMap")
|
| 36 |
+
data_url = image_to_data_url(overlay)
|
| 37 |
+
folium.raster_layers.ImageOverlay(
|
| 38 |
+
image=data_url,
|
| 39 |
+
bounds=[bounds[0], bounds[1]],
|
| 40 |
+
opacity=opacity,
|
| 41 |
+
name="Recolored (NWS)",
|
| 42 |
+
interactive=False,
|
| 43 |
+
cross_origin=False,
|
| 44 |
+
).add_to(m)
|
| 45 |
+
if add_raw_wms:
|
| 46 |
+
# Add the original MSC WMS layer for comparison
|
| 47 |
+
WmsTileLayer(
|
| 48 |
+
url=wms_url,
|
| 49 |
+
layers=wms_layer,
|
| 50 |
+
version="1.3.0",
|
| 51 |
+
transparent=True,
|
| 52 |
+
format="image/png",
|
| 53 |
+
name="Raw MSC WMS",
|
| 54 |
+
overlay=True,
|
| 55 |
+
control=True,
|
| 56 |
+
opacity=opacity,
|
| 57 |
+
attr='<a href="https://eccc-msc.github.io/open-data/licence/readme_en/">ECCC</a>',
|
| 58 |
+
).add_to(m)
|
| 59 |
+
folium.LayerControl().add_to(m)
|
| 60 |
+
return m
|
pyproject.toml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "pydardraw"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Radar image processing: fetch, dBZ quantization, recoloring"
|
| 9 |
+
authors = [{ name = "Your Name" }]
|
| 10 |
+
readme = "README.md"
|
| 11 |
+
requires-python = ">=3.9"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"numpy>=1.23",
|
| 14 |
+
"Pillow>=9.0",
|
| 15 |
+
"requests>=2.31",
|
| 16 |
+
"gradio>=4.0",
|
| 17 |
+
"folium>=0.14",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
opencv = [
|
| 22 |
+
"opencv-python>=4.7",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[project.urls]
|
| 26 |
+
Homepage = "https://example.com"
|
| 27 |
+
|
| 28 |
+
[tool.setuptools]
|
| 29 |
+
zip-safe = false
|
| 30 |
+
|
| 31 |
+
[tool.setuptools.packages.find]
|
| 32 |
+
include = ["pydardraw*"]
|
| 33 |
+
exclude = ["apps*", "scripts*"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0
|
| 2 |
+
numpy>=1.23
|
| 3 |
+
Pillow>=9.0
|
| 4 |
+
requests>=2.31
|
| 5 |
+
folium>=0.14
|
| 6 |
+
|
| 7 |
+
# Optional (perceptual color distances). Commented to keep build light.
|
| 8 |
+
# opencv-python>=4.7
|
| 9 |
+
|
runtime.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
python-3.10
|
scripts/recolor_cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import argparse
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from pydardraw.colormaps.io import load_colormap_from_json
|
| 8 |
+
from pydardraw.colormaps.canada_msc import CANADA_MSC_REF
|
| 9 |
+
from pydardraw.colormaps.nws_us import NWS_REF
|
| 10 |
+
from pydardraw.processing.convert import image_to_dbz, dbz_to_image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
ap = argparse.ArgumentParser(description="Recolor a radar image to a target dBZ colormap")
|
| 15 |
+
ap.add_argument("input", type=Path, help="Input image (PNG)")
|
| 16 |
+
ap.add_argument("output", type=Path, help="Output image (PNG)")
|
| 17 |
+
ap.add_argument("--src-colormap", type=Path, help="JSON colormap for source (value, #RRGGBB)")
|
| 18 |
+
ap.add_argument("--dst-colormap", type=Path, help="JSON colormap for destination (value, #RRGGBB)")
|
| 19 |
+
ap.add_argument("--fractional", action="store_true", help="Estimate fractional dBZ (xx.xx) using LAB projection")
|
| 20 |
+
args = ap.parse_args()
|
| 21 |
+
|
| 22 |
+
src_map = load_colormap_from_json(args.src_colormap) if args.src_colormap else CANADA_MSC_REF
|
| 23 |
+
dst_map = load_colormap_from_json(args.dst_colormap) if args.dst_colormap else NWS_REF
|
| 24 |
+
|
| 25 |
+
img = Image.open(args.input).convert("RGBA")
|
| 26 |
+
dbz, alpha = image_to_dbz(img, src_map, fractional=args.fractional)
|
| 27 |
+
out = dbz_to_image(dbz, alpha, dst_map)
|
| 28 |
+
out.save(args.output)
|
| 29 |
+
print(f"Saved: {args.output}")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
if __name__ == "__main__":
|
| 33 |
+
main()
|
scripts/run_demo.sh
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# Run the Canada → US NWS recolor Gradio demo with minimal fuss.
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# bash scripts/run_demo.sh [--share] [--port <PORT|auto>] [--opencv]
|
| 8 |
+
#
|
| 9 |
+
# Examples:
|
| 10 |
+
# bash scripts/run_demo.sh --port 7861
|
| 11 |
+
# bash scripts/run_demo.sh --share --port 7861
|
| 12 |
+
# bash scripts/run_demo.sh --opencv --port auto
|
| 13 |
+
|
| 14 |
+
PORT="7861"
|
| 15 |
+
SHARE="0"
|
| 16 |
+
WITH_OPENCV="0"
|
| 17 |
+
|
| 18 |
+
while [[ $# -gt 0 ]]; do
|
| 19 |
+
case "$1" in
|
| 20 |
+
--share)
|
| 21 |
+
SHARE="1"; shift ;;
|
| 22 |
+
--opencv)
|
| 23 |
+
WITH_OPENCV="1"; shift ;;
|
| 24 |
+
--port)
|
| 25 |
+
PORT="${2:-7861}"; shift 2 ;;
|
| 26 |
+
-h|--help)
|
| 27 |
+
echo "Usage: $0 [--share] [--port <PORT|auto>] [--opencv]"; exit 0 ;;
|
| 28 |
+
*)
|
| 29 |
+
echo "Unknown arg: $1"; exit 1 ;;
|
| 30 |
+
esac
|
| 31 |
+
done
|
| 32 |
+
|
| 33 |
+
# Resolve repo root (this script lives in scripts/)
|
| 34 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 35 |
+
REPO_ROOT="${SCRIPT_DIR%/scripts}"
|
| 36 |
+
cd "$REPO_ROOT"
|
| 37 |
+
|
| 38 |
+
# Pick Python
|
| 39 |
+
if command -v python3 >/dev/null 2>&1; then
|
| 40 |
+
PY=python3
|
| 41 |
+
else
|
| 42 |
+
PY=python
|
| 43 |
+
fi
|
| 44 |
+
|
| 45 |
+
# Create venv if missing
|
| 46 |
+
if [[ ! -d .venv ]]; then
|
| 47 |
+
echo "[+] Creating virtualenv .venv"
|
| 48 |
+
"$PY" -m venv .venv
|
| 49 |
+
fi
|
| 50 |
+
|
| 51 |
+
echo "[+] Activating venv"
|
| 52 |
+
source .venv/bin/activate
|
| 53 |
+
|
| 54 |
+
echo "[+] Upgrading pip"
|
| 55 |
+
python -m pip install -U pip wheel setuptools >/dev/null
|
| 56 |
+
|
| 57 |
+
if [[ "$WITH_OPENCV" == "1" ]]; then
|
| 58 |
+
echo "[+] Installing package with OpenCV extras (for LAB precision)"
|
| 59 |
+
pip install -e '.[opencv]'
|
| 60 |
+
else
|
| 61 |
+
echo "[+] Installing package"
|
| 62 |
+
pip install -e .
|
| 63 |
+
fi
|
| 64 |
+
|
| 65 |
+
# Configure Gradio env
|
| 66 |
+
if [[ "$PORT" != "auto" ]]; then
|
| 67 |
+
export GRADIO_SERVER_PORT="$PORT"
|
| 68 |
+
else
|
| 69 |
+
# Let Gradio auto-pick a free port by not setting the env var
|
| 70 |
+
unset GRADIO_SERVER_PORT 2>/dev/null || true
|
| 71 |
+
fi
|
| 72 |
+
export GRADIO_SHARE="$SHARE"
|
| 73 |
+
|
| 74 |
+
echo "[+] Launching Gradio demo (share=$SHARE, port=${PORT})"
|
| 75 |
+
echo " Press Ctrl+C to stop."
|
| 76 |
+
|
| 77 |
+
# Try to open the local URL automatically if a fixed port is chosen
|
| 78 |
+
if [[ "$SHARE" != "1" && "$PORT" != "auto" ]]; then
|
| 79 |
+
LOCAL_URL="http://127.0.0.1:${PORT}"
|
| 80 |
+
if command -v open >/dev/null 2>&1; then
|
| 81 |
+
(sleep 2; open "$LOCAL_URL") &
|
| 82 |
+
elif command -v xdg-open >/dev/null 2>&1; then
|
| 83 |
+
(sleep 2; xdg-open "$LOCAL_URL") &
|
| 84 |
+
fi
|
| 85 |
+
echo " When ready, your browser should open ${LOCAL_URL}"
|
| 86 |
+
fi
|
| 87 |
+
|
| 88 |
+
python apps/canada_radar_gradio.py
|
| 89 |
+
|