Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,32 +1,38 @@
|
|
| 1 |
-
import os,
|
| 2 |
import numpy as np
|
| 3 |
import torch
|
| 4 |
import torch.nn as nn
|
| 5 |
import requests
|
| 6 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
#
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 65 |
-
|
| 66 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
except Exception as e:
|
| 258 |
-
print(f"
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '© CARTO © 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)
|