NovatasticRoScript commited on
Commit
1ec6543
ยท
verified ยท
1 Parent(s): 76ada68

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +192 -112
app.py CHANGED
@@ -1,32 +1,38 @@
1
- import os, math, json, io
2
  import numpy as np
3
  import torch
4
  import torch.nn as nn
5
  import requests
6
- import imageio.v3 as iio
7
- import gradio as gr
8
- from PIL import Image, ImageDraw, ImageFont
9
  from matplotlib.colors import LinearSegmentedColormap
10
  from huggingface_hub import hf_hub_download, HfApi, create_repo
11
  from pysteps.motion.lucaskanade import dense_lucaskanade
12
  from pysteps.extrapolation.semilagrangian import extrapolate
13
  from pyproj import Proj
14
  from datetime import datetime, timedelta
 
 
 
 
15
 
16
  HF_TOKEN=os.environ.get("HF_TOKEN","")
17
  MODEL_REPO="NovatasticRoScript/himawari-nowcast"
18
  DATASET_REPO="NovatasticRoScript/himawari-live-cache"
19
  SEQ=9; PRED=18; LK=4; W=H=640; IN_CH=6
20
- DISPLAY_BBOX=(103.,-3.,139.,35.)
21
  PAR_POLY=[(115.,5.),(115.,15.),(120.,21.),(120.,25.),(135.,25.),(135.,5.)]
22
- NEIGHBOR_COUNTRIES=["Philippines","Taiwan","Vietnam","Malaysia","Indonesia",
23
- "China","Japan","Brunei","Palau"]
24
  SLIDER="https://slider.cira.colostate.edu"
25
- ZOOM=3 # tile zoom level for regional crop (0=full disk thumbnail, higher=sharper, more tiles)
 
 
 
 
26
 
27
- # --- Himawari full-disk geostationary projection ---
 
 
28
  _HIMA_PROJ = Proj(proj='geos', h=35785863.0, lon_0=140.7, a=6378137.0, b=6356752.3, sweep='x')
29
- _FULL_DISK_EXTENT = 5500000.035308 # meters, half-width/height of full-disk image
30
 
31
  def _lonlat_to_frac(lon, lat):
32
  x, y = _HIMA_PROJ(lon, lat)
@@ -44,7 +50,6 @@ def _bbox_frac_range():
44
  return min(fxs), max(fxs), min(fys), max(fys)
45
 
46
  def _crop_resize_to_region(full_disk_arr):
47
- """Fallback: crop the single full-disk zoom-0 image (low-res)."""
48
  h, w = full_disk_arr.shape
49
  fx0, fx1, fy0, fy1 = _bbox_frac_range()
50
  x0, x1 = int(fx0*w), int(fx1*w)
@@ -61,37 +66,9 @@ def _bbox_tile_range(zoom):
61
  row0,row1 = max(0,int(fy0*n)), min(n-1,int(fy1*n))
62
  return row0,row1,col0,col1,n
63
 
64
- # --- Country borders/coastlines ---
65
- def _fetch_geojson():
66
- try:
67
- return requests.get(
68
- "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson",
69
- timeout=15).json()
70
- except Exception:
71
- return None
72
- _GEOJSON = _fetch_geojson()
73
-
74
- def _rasterize_land_mask(w, h, bbox, names):
75
- lon_min,lat_min,lon_max,lat_max = bbox
76
- img = Image.new("L",(w,h),0)
77
- if not _GEOJSON:
78
- return np.zeros((h,w),dtype=np.float32)
79
- canvas = ImageDraw.Draw(img)
80
- wanted = set(names)
81
- for f in _GEOJSON.get("features",[]):
82
- if f.get("properties",{}).get("NAME") not in wanted: continue
83
- geom = f.get("geometry",{})
84
- coords = [geom.get("coordinates",[])[0]] if geom.get("type")=="Polygon" else [p[0] for p in geom.get("coordinates",[])]
85
- for ring in coords:
86
- pts = [((lon-lon_min)/(lon_max-lon_min)*w,(lat_max-lat)/(lat_max-lat_min)*h) for lon,lat in ring]
87
- if len(pts)>=3: canvas.polygon(pts,fill=255)
88
- return np.array(img,dtype=np.float32)/255.
89
-
90
- _WIDE_MASK = _rasterize_land_mask(W,H,DISPLAY_BBOX,NEIGHBOR_COUNTRIES)
91
- _COASTLINE = np.zeros_like(_WIDE_MASK,dtype=bool)
92
- _COASTLINE[:-1,:]|=(_WIDE_MASK[:-1,:]>0.5)!=(_WIDE_MASK[1:,:]>0.5)
93
- _COASTLINE[:,:-1]|=(_WIDE_MASK[:,:-1]>0.5)!=(_WIDE_MASK[:,1:]>0.5)
94
-
95
  ir_colors=[(0.00,(0.05,0.05,0.05)),(0.30,(0.15,0.20,0.35)),(0.50,(0.00,0.65,0.90)),
96
  (0.65,(0.00,0.75,0.00)),(0.80,(1.00,0.85,0.00)),(0.92,(0.90,0.10,0.00)),
97
  (1.00,(1.00,1.00,1.00))]
@@ -119,6 +96,11 @@ except Exception as e:
119
  print(f"Model load failed: {e}")
120
  model.eval()
121
 
 
 
 
 
 
122
  def slider_ts(product="band_13"):
123
  url=f"{SLIDER}/data/json/himawari/full_disk/{product}/latest_times.json"
124
  r=requests.get(url,timeout=10)
@@ -154,7 +136,6 @@ def fetch_slider_region(ts, zoom=ZOOM, product="band_13"):
154
  arr=np.array(img,dtype=np.float32)/255.
155
  tiles[(row,col)]=arr
156
  if tile_h is None: tile_h,tile_w=arr.shape
157
- print(f"TILE z{zoom} {row:03d}_{col:03d} OK {arr.shape}")
158
  except Exception as e:
159
  print(f"TILE z{zoom} {row:03d}_{col:03d} FAILED: {e}")
160
  if not tiles:
@@ -202,74 +183,173 @@ def build_live_seq():
202
  frames.insert(0,frames[0])
203
  return np.array(frames[-SEQ:]),base
204
 
205
- zeros_t=torch.zeros(1,1,H,W); coast_t=torch.zeros(1,1,H,W)
206
- aoi=[((lon-DISPLAY_BBOX[0])/(DISPLAY_BBOX[2]-DISPLAY_BBOX[0])*W,
207
- (DISPLAY_BBOX[3]-lat)/(DISPLAY_BBOX[3]-DISPLAY_BBOX[1])*H) for lon,lat in PAR_POLY]
208
- try:
209
- font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",14)
210
- except:
211
- font=ImageFont.load_default()
212
-
213
- def run_forecast(uploaded=None):
214
- if uploaded:
215
- try: raw=list(iio.imiter(uploaded))
216
- except: raw=[iio.imread(uploaded)]
217
- imgs=[np.array(Image.fromarray(f).convert("L").resize((W,H)),dtype=np.float32)/255. for f in raw]
218
- while len(imgs)<SEQ: imgs.append(imgs[-1])
219
- history=np.array(imgs[-SEQ:]); base=datetime.utcnow(); mode="UPLOAD"
220
- else:
221
- history,base=build_live_seq(); mode=f"SLIDER LIVE {base:%H:%M}Z"
222
-
223
- v=dense_lucaskanade(history[-LK:],verbose=False)
224
- hor=np.array(extrapolate(history[-1],v,timesteps=PRED,outval="min"))
225
-
226
- curr=torch.tensor(history[-1],dtype=torch.float32).unsqueeze(0).unsqueeze(0)
227
- last=torch.tensor(history[-2],dtype=torch.float32).unsqueeze(0).unsqueeze(0)
228
- preds=[]
229
- with torch.no_grad():
230
- for i in range(PRED):
231
- prior=torch.tensor(hor[i],dtype=torch.float32).unsqueeze(0).unsqueeze(0)
232
- ref=model(curr,zeros_t,zeros_t,prior,curr-last,coast_t)
233
- preds.append(ref.squeeze().numpy()); last=curr; curr=ref
234
-
235
- def frame(data,cap,fcst=False):
236
- rgb=(cmap(np.clip(data,0,1))[...,:3]*255).astype(np.uint8)
237
- rgb[_COASTLINE]=[0,255,80]
238
- img=Image.fromarray(rgb); d=ImageDraw.Draw(img)
239
- d.line(aoi+[aoi[0]],fill=(255,0,0),width=2)
240
- d.rectangle([0,0,W,20],fill=(0,0,0))
241
- d.text((6,2),cap,fill=(255,255,255) if not fcst else (255,215,0),font=font)
242
- return np.array(img)
243
-
244
- frames=[frame(history[i],f"OBS T-{(SEQ-1-i)*10:02d}min | {mode}") for i in range(SEQ)]
245
- frames+=[frame(preds[i],f"FCST T+{(i+1)*10:02d}min",True) for i in range(PRED)]
246
- out="/tmp/forecast.gif"
247
- iio.imwrite(out,frames,plugin="pillow",extension=".gif",loop=0,duration=220)
248
-
249
- if HF_TOKEN and not uploaded:
250
  try:
251
- api=HfApi(token=HF_TOKEN)
252
- create_repo(DATASET_REPO,repo_type="dataset",token=HF_TOKEN,exist_ok=True)
253
- fname=f"live_{base:%Y%m%d_%H%M}.npy"
254
- npy=f"/tmp/{fname}"; np.save(npy,history[-1])
255
- api.upload_file(path_or_fileobj=npy,path_in_repo=f"frames/{fname}",
256
- repo_id=DATASET_REPO,repo_type="dataset",token=HF_TOKEN)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  except Exception as e:
258
- print(f"Dataset push: {e}")
259
- return out
260
-
261
- with gr.Blocks(theme=gr.themes.Monochrome()) as demo:
262
- gr.Markdown("# ๐ŸŒ€ Himawari-9 PySTEPS + Neural Correction Nowcast")
263
- gr.Markdown("CIRA SLIDER IR frames โ†’ pysteps optical-flow โ†’ CNN residual correction. "
264
- "3hr forecast, official PAR hexagon, real country borders. "
265
- "Each live forecast auto-saves to HF Dataset for Colab self-improvement retraining.")
266
- with gr.Tab("Live Forecast"):
267
- btn=gr.Button("โšก Generate Forecast",variant="primary")
268
- out=gr.Image(label="Forecast GIF")
269
- btn.click(fn=lambda:run_forecast(None),inputs=[],outputs=[out])
270
- with gr.Tab("Upload Your Own"):
271
- f=gr.File(label="IR image or GIF",type="filepath")
272
- btn2=gr.Button("๐Ÿง  Predict",variant="primary")
273
- out2=gr.Image(label="Forecast GIF")
274
- btn2.click(fn=run_forecast,inputs=[f],outputs=[out2])
275
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, json, time, threading, io
2
  import numpy as np
3
  import torch
4
  import torch.nn as nn
5
  import requests
6
+ from PIL import Image
 
 
7
  from matplotlib.colors import LinearSegmentedColormap
8
  from huggingface_hub import hf_hub_download, HfApi, create_repo
9
  from pysteps.motion.lucaskanade import dense_lucaskanade
10
  from pysteps.extrapolation.semilagrangian import extrapolate
11
  from pyproj import Proj
12
  from datetime import datetime, timedelta
13
+ import gradio as gr
14
+ from fastapi import FastAPI
15
+ from fastapi.staticfiles import StaticFiles
16
+ import uvicorn
17
 
18
  HF_TOKEN=os.environ.get("HF_TOKEN","")
19
  MODEL_REPO="NovatasticRoScript/himawari-nowcast"
20
  DATASET_REPO="NovatasticRoScript/himawari-live-cache"
21
  SEQ=9; PRED=18; LK=4; W=H=640; IN_CH=6
22
+ DISPLAY_BBOX=(103.,-3.,139.,35.) # lon_min, lat_min, lon_max, lat_max
23
  PAR_POLY=[(115.,5.),(115.,15.),(120.,21.),(120.,25.),(135.,25.),(135.,5.)]
 
 
24
  SLIDER="https://slider.cira.colostate.edu"
25
+ ZOOM=3
26
+ FRAMES_DIR="/tmp/frames"
27
+ UPDATE_INTERVAL_SEC=600 # 10 minutes
28
+
29
+ os.makedirs(FRAMES_DIR, exist_ok=True)
30
 
31
+ # =============================================================================
32
+ # Himawari full-disk geostationary projection (for cropping to DISPLAY_BBOX)
33
+ # =============================================================================
34
  _HIMA_PROJ = Proj(proj='geos', h=35785863.0, lon_0=140.7, a=6378137.0, b=6356752.3, sweep='x')
35
+ _FULL_DISK_EXTENT = 5500000.035308
36
 
37
  def _lonlat_to_frac(lon, lat):
38
  x, y = _HIMA_PROJ(lon, lat)
 
50
  return min(fxs), max(fxs), min(fys), max(fys)
51
 
52
  def _crop_resize_to_region(full_disk_arr):
 
53
  h, w = full_disk_arr.shape
54
  fx0, fx1, fy0, fy1 = _bbox_frac_range()
55
  x0, x1 = int(fx0*w), int(fx1*w)
 
66
  row0,row1 = max(0,int(fy0*n)), min(n-1,int(fy1*n))
67
  return row0,row1,col0,col1,n
68
 
69
+ # =============================================================================
70
+ # Colormap + model
71
+ # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  ir_colors=[(0.00,(0.05,0.05,0.05)),(0.30,(0.15,0.20,0.35)),(0.50,(0.00,0.65,0.90)),
73
  (0.65,(0.00,0.75,0.00)),(0.80,(1.00,0.85,0.00)),(0.92,(0.90,0.10,0.00)),
74
  (1.00,(1.00,1.00,1.00))]
 
96
  print(f"Model load failed: {e}")
97
  model.eval()
98
 
99
+ zeros_t=torch.zeros(1,1,H,W); coast_t=torch.zeros(1,1,H,W)
100
+
101
+ # =============================================================================
102
+ # SLIDER fetch (zoom-tile mosaic, falls back to full-disk crop)
103
+ # =============================================================================
104
  def slider_ts(product="band_13"):
105
  url=f"{SLIDER}/data/json/himawari/full_disk/{product}/latest_times.json"
106
  r=requests.get(url,timeout=10)
 
136
  arr=np.array(img,dtype=np.float32)/255.
137
  tiles[(row,col)]=arr
138
  if tile_h is None: tile_h,tile_w=arr.shape
 
139
  except Exception as e:
140
  print(f"TILE z{zoom} {row:03d}_{col:03d} FAILED: {e}")
141
  if not tiles:
 
183
  frames.insert(0,frames[0])
184
  return np.array(frames[-SEQ:]),base
185
 
186
+ def save_frame_png(data, path):
187
+ rgb = (cmap(np.clip(data,0,1))[...,:3]*255).astype(np.uint8)
188
+ Image.fromarray(rgb).save(path)
189
+
190
+ # =============================================================================
191
+ # Background update loop: runs forever, refreshes frames every 10 minutes
192
+ # =============================================================================
193
+ def update_loop():
194
+ while True:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  try:
196
+ history, base = build_live_seq()
197
+ v = dense_lucaskanade(history[-LK:], verbose=False)
198
+ hor = np.array(extrapolate(history[-1], v, timesteps=PRED, outval="min"))
199
+
200
+ curr = torch.tensor(history[-1], dtype=torch.float32).unsqueeze(0).unsqueeze(0)
201
+ last = torch.tensor(history[-2], dtype=torch.float32).unsqueeze(0).unsqueeze(0)
202
+ preds=[]
203
+ with torch.no_grad():
204
+ for i in range(PRED):
205
+ prior = torch.tensor(hor[i], dtype=torch.float32).unsqueeze(0).unsqueeze(0)
206
+ ref = model(curr, zeros_t, zeros_t, prior, curr-last, coast_t)
207
+ preds.append(ref.squeeze().numpy()); last=curr; curr=ref
208
+
209
+ for i in range(SEQ):
210
+ save_frame_png(history[i], os.path.join(FRAMES_DIR, f"obs_{i}.png"))
211
+ for i in range(PRED):
212
+ save_frame_png(preds[i], os.path.join(FRAMES_DIR, f"fcst_{i}.png"))
213
+
214
+ manifest = {
215
+ "ready": True,
216
+ "base_time": base.strftime("%Y-%m-%dT%H:%M:%SZ"),
217
+ "obs_count": SEQ, "fcst_count": PRED,
218
+ "obs_step_min": 10, "fcst_step_min": 10,
219
+ "version": int(time.time()),
220
+ }
221
+ with open(os.path.join(FRAMES_DIR,"manifest.json"),"w") as f:
222
+ json.dump(manifest, f)
223
+ print(f"โœ… Updated frames @ {base} UTC")
224
+
225
+ if HF_TOKEN:
226
+ try:
227
+ api = HfApi(token=HF_TOKEN)
228
+ create_repo(DATASET_REPO, repo_type="dataset", token=HF_TOKEN, exist_ok=True)
229
+ fname = f"live_{base:%Y%m%d_%H%M}.npy"
230
+ npy_path = f"/tmp/{fname}"
231
+ np.save(npy_path, history[-1])
232
+ api.upload_file(path_or_fileobj=npy_path, path_in_repo=f"frames/{fname}",
233
+ repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
234
+ except Exception as e:
235
+ print(f"Dataset push failed: {e}")
236
+
237
  except Exception as e:
238
+ print(f"โš ๏ธ Update loop error: {e}")
239
+
240
+ time.sleep(UPDATE_INTERVAL_SEC)
241
+
242
+ if not os.path.exists(os.path.join(FRAMES_DIR,"manifest.json")):
243
+ with open(os.path.join(FRAMES_DIR,"manifest.json"),"w") as f:
244
+ json.dump({"ready": False, "version": 0}, f)
245
+
246
+ threading.Thread(target=update_loop, daemon=True).start()
247
+
248
+ # =============================================================================
249
+ # Map UI (Leaflet, served as static HTML/JS inside a Gradio Blocks page)
250
+ # =============================================================================
251
+ _bounds_js = json.dumps([[DISPLAY_BBOX[1],DISPLAY_BBOX[0]],[DISPLAY_BBOX[3],DISPLAY_BBOX[2]]])
252
+ _par_js = json.dumps([[lat,lon] for lon,lat in PAR_POLY])
253
+
254
+ _MAP_HTML_TEMPLATE = r"""
255
+ <div id="liveMap" style="width:100%;height:560px;border-radius:8px;"></div>
256
+ <div style="margin-top:8px;display:flex;align-items:center;gap:10px;font-family:monospace;">
257
+ <button id="liveBtn" style="padding:6px 14px;border-radius:6px;border:none;background:#e63946;color:white;font-weight:bold;cursor:pointer;">๐Ÿ”ด LIVE</button>
258
+ <span id="frameLabel" style="min-width:260px;">Loading...</span>
259
+ <input id="frameSlider" type="range" min="0" max="1" value="0" style="flex:1;">
260
+ </div>
261
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css"/>
262
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
263
+ <script>
264
+ (function(){
265
+ const BOUNDS = __BOUNDS__;
266
+ const PAR = __PAR__;
267
+ const map = L.map('liveMap', {zoomControl:true});
268
+ map.fitBounds(BOUNDS);
269
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
270
+ attribution: '&copy; CARTO &copy; OpenStreetMap contributors',
271
+ subdomains: 'abcd', maxZoom: 19
272
+ }).addTo(map);
273
+ L.rectangle(BOUNDS, {color:'#888', weight:1, dashArray:'4', fill:false}).addTo(map);
274
+ L.polygon(PAR, {color:'#ff3b30', weight:2, fill:false}).addTo(map);
275
+
276
+ let overlay = L.imageOverlay('/frames/obs_0.png', BOUNDS, {opacity:0.78}).addTo(map);
277
+
278
+ let manifest = null;
279
+ let live = true;
280
+ let lastVersion = -1;
281
+
282
+ const slider = document.getElementById('frameSlider');
283
+ const label = document.getElementById('frameLabel');
284
+ const liveBtn = document.getElementById('liveBtn');
285
+
286
+ function frameName(idx){
287
+ if (!manifest) return null;
288
+ if (idx < manifest.obs_count) {
289
+ const minsAgo = (manifest.obs_count - 1 - idx) * manifest.obs_step_min;
290
+ return {file: 'obs_' + idx + '.png', label: 'OBS T-' + minsAgo + 'min'};
291
+ } else {
292
+ const fi = idx - manifest.obs_count;
293
+ const minsFwd = (fi + 1) * manifest.fcst_step_min;
294
+ return {file: 'fcst_' + fi + '.png', label: 'FCST T+' + minsFwd + 'min'};
295
+ }
296
+ }
297
+
298
+ function showFrame(idx){
299
+ const fr = frameName(idx);
300
+ if (!fr) return;
301
+ overlay.setUrl('/frames/' + fr.file + '?t=' + Date.now());
302
+ label.textContent = fr.label + (manifest.base_time ? (' | base ' + manifest.base_time) : '');
303
+ slider.value = idx;
304
+ }
305
+
306
+ function applyManifest(m){
307
+ manifest = m;
308
+ const total = m.obs_count + m.fcst_count;
309
+ slider.max = total - 1;
310
+ if (live) showFrame(m.obs_count - 1);
311
+ }
312
+
313
+ function poll(){
314
+ fetch('/frames/manifest.json?t=' + Date.now())
315
+ .then(r => r.json())
316
+ .then(m => {
317
+ if (!m.ready) return;
318
+ if (m.version !== lastVersion) {
319
+ lastVersion = m.version;
320
+ applyManifest(m);
321
+ }
322
+ })
323
+ .catch(()=>{});
324
+ }
325
+
326
+ slider.addEventListener('input', function(){
327
+ live = false;
328
+ liveBtn.style.opacity = 0.45;
329
+ showFrame(parseInt(slider.value));
330
+ });
331
+
332
+ liveBtn.addEventListener('click', function(){
333
+ live = true;
334
+ liveBtn.style.opacity = 1;
335
+ if (manifest) showFrame(manifest.obs_count - 1);
336
+ });
337
+
338
+ poll();
339
+ setInterval(poll, 20000);
340
+ })();
341
+ </script>
342
+ """
343
+
344
+ MAP_HTML = _MAP_HTML_TEMPLATE.replace("__BOUNDS__", _bounds_js).replace("__PAR__", _par_js)
345
+
346
+ with gr.Blocks(title="Himawari PAR Nowcast") as demo:
347
+ gr.Markdown("### ๐ŸŒ€ Himawari-9 Live Nowcast โ€” Philippine Area of Responsibility")
348
+ gr.HTML(MAP_HTML)
349
+
350
+ fastapi_app = FastAPI()
351
+ fastapi_app.mount("/frames", StaticFiles(directory=FRAMES_DIR), name="frames")
352
+ fastapi_app = gr.mount_gradio_app(fastapi_app, demo, path="/")
353
+
354
+ if __name__ == "__main__":
355
+ uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)