nakas commited on
Commit
bf55076
·
1 Parent(s): 31bfb24
Files changed (41) hide show
  1. .DS_Store +0 -0
  2. README.md +65 -0
  3. app.py +32 -0
  4. apps/__init__.py +1 -0
  5. apps/canada_radar_gradio.py +166 -0
  6. pydardraw.egg-info/PKG-INFO +78 -0
  7. pydardraw.egg-info/SOURCES.txt +20 -0
  8. pydardraw.egg-info/dependency_links.txt +1 -0
  9. pydardraw.egg-info/not-zip-safe +1 -0
  10. pydardraw.egg-info/requires.txt +8 -0
  11. pydardraw.egg-info/top_level.txt +1 -0
  12. pydardraw/.DS_Store +0 -0
  13. pydardraw/__init__.py +8 -0
  14. pydardraw/__pycache__/__init__.cpython-312.pyc +0 -0
  15. pydardraw/colormaps/.DS_Store +0 -0
  16. pydardraw/colormaps/__pycache__/base.cpython-312.pyc +0 -0
  17. pydardraw/colormaps/__pycache__/canada_msc.cpython-312.pyc +0 -0
  18. pydardraw/colormaps/__pycache__/derive.cpython-312.pyc +0 -0
  19. pydardraw/colormaps/__pycache__/gradient.cpython-312.pyc +0 -0
  20. pydardraw/colormaps/__pycache__/nws_us.cpython-312.pyc +0 -0
  21. pydardraw/colormaps/base.py +123 -0
  22. pydardraw/colormaps/canada_msc.py +27 -0
  23. pydardraw/colormaps/derive.py +96 -0
  24. pydardraw/colormaps/gradient.py +177 -0
  25. pydardraw/colormaps/io.py +30 -0
  26. pydardraw/colormaps/nws_us.py +27 -0
  27. pydardraw/processing/__pycache__/components.cpython-312.pyc +0 -0
  28. pydardraw/processing/__pycache__/convert.cpython-312.pyc +0 -0
  29. pydardraw/processing/components.py +114 -0
  30. pydardraw/processing/convert.py +91 -0
  31. pydardraw/sources/__pycache__/msc_colormap.cpython-312.pyc +0 -0
  32. pydardraw/sources/__pycache__/msc_geomet.cpython-312.pyc +0 -0
  33. pydardraw/sources/msc_colormap.py +71 -0
  34. pydardraw/sources/msc_geomet.py +69 -0
  35. pydardraw/viz/__pycache__/folium_overlay.cpython-312.pyc +0 -0
  36. pydardraw/viz/folium_overlay.py +60 -0
  37. pyproject.toml +33 -0
  38. requirements.txt +9 -0
  39. runtime.txt +1 -0
  40. scripts/recolor_cli.py +33 -0
  41. 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
+