1151 lines
43 KiB
Python
1151 lines
43 KiB
Python
"""
|
||
Wildlife Monitoring Dashboard — Yellowstone National Park
|
||
|
||
Flask app with two pages:
|
||
/ – fullscreen map with camera markers + togglable sidebar
|
||
/det/<id> – detection detail page with all XAI visualisations
|
||
|
||
Run: uv run python dashboard.py
|
||
"""
|
||
|
||
import csv
|
||
import json
|
||
import random
|
||
import threading
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.cm as mpl_cm
|
||
from PIL import Image, ImageDraw
|
||
|
||
import torch
|
||
from torch import nn
|
||
from torch.utils.data import Dataset, DataLoader
|
||
from torchvision import transforms
|
||
from torchvision.models import efficientnet_v2_s, EfficientNet_V2_S_Weights
|
||
from pytorch_grad_cam import ScoreCAM
|
||
from pytorch_grad_cam.utils.image import show_cam_on_image
|
||
from lime import lime_image
|
||
from skimage.segmentation import mark_boundaries
|
||
from flask import Flask, render_template_string, jsonify, send_from_directory, request
|
||
|
||
# ── constants ─────────────────────────────────────────────────────────────────
|
||
|
||
SEED = 42
|
||
random.seed(SEED)
|
||
np.random.seed(SEED)
|
||
torch.manual_seed(SEED)
|
||
|
||
CLASS_NAMES = ["bear", "deer", "fox", "hare", "moose", "person", "wolf"]
|
||
WEIGHTS_PATH = Path("efficientnet_v2_wild_forest_animals.pt")
|
||
DATASET_DIR = Path("wild-forest-animals-and-person-1")
|
||
XAI_DIR = Path("_xai_cache")
|
||
XAI_DIR.mkdir(exist_ok=True)
|
||
|
||
# Camera positions as percentages of the map image (adjust to match map.webp)
|
||
CAMERAS = {
|
||
"CAM-01": {"name": "Lamar Valley", "px": 65, "py": 17,
|
||
"desc": "Northeast corridor, prime wolf and bison territory"},
|
||
"CAM-02": {"name": "Hayden Valley", "px": 48, "py": 38,
|
||
"desc": "Central meadows between canyon and lake"},
|
||
"CAM-03": {"name": "Mammoth Hot Springs", "px": 28, "py": 12,
|
||
"desc": "Northern range, year-round elk habitat"},
|
||
"CAM-04": {"name": "Old Faithful", "px": 24, "py": 54,
|
||
"desc": "Upper Geyser Basin, forested southwest"},
|
||
"CAM-05": {"name": "Yellowstone Lake", "px": 55, "py": 48,
|
||
"desc": "Eastern shoreline, moose and waterfowl corridor"},
|
||
}
|
||
|
||
SPECIES_ICON = {
|
||
"bear": "\U0001f43b", "deer": "\U0001f98c", "fox": "\U0001f98a",
|
||
"hare": "\U0001f407", "moose": "\U0001f98c", "person": "\U0001f9d1",
|
||
"wolf": "\U0001f43a",
|
||
}
|
||
|
||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||
|
||
# ── dataset ───────────────────────────────────────────────────────────────────
|
||
|
||
class WildForestAnimalsDataset(Dataset):
|
||
def __init__(self, root, split, transform=None):
|
||
self.root, self.transform = Path(root), transform
|
||
split_dir = self.root / split
|
||
self.samples = []
|
||
with (split_dir / "_classes.csv").open(newline="") as f:
|
||
for row in csv.DictReader(f):
|
||
oh = [int(row[n]) for n in CLASS_NAMES]
|
||
try:
|
||
label = oh.index(1)
|
||
except ValueError:
|
||
continue
|
||
self.samples.append((split_dir / row["filename"], label))
|
||
|
||
def __len__(self):
|
||
return len(self.samples)
|
||
|
||
def __getitem__(self, idx):
|
||
p, l = self.samples[idx]
|
||
img = Image.open(p).convert("RGB")
|
||
return self.transform(img) if self.transform else img, l
|
||
|
||
|
||
_eval_tf = transforms.Compose([
|
||
transforms.ToTensor(),
|
||
transforms.Resize((224, 224), antialias=True),
|
||
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||
])
|
||
|
||
# ── dataset auto-download ──────────────────────────────────────────────────
|
||
|
||
if not DATASET_DIR.exists():
|
||
print("Dataset not found -- downloading from Roboflow ...")
|
||
from roboflow import Roboflow
|
||
rf = Roboflow(api_key="VCZWezdoCHQz7juipBdt")
|
||
project = rf.workspace("forestanimals").project("wild-forest-animals-and-person")
|
||
project.version(1).download("multiclass")
|
||
if not DATASET_DIR.exists():
|
||
raise RuntimeError(f"Download finished but {DATASET_DIR} not found.")
|
||
|
||
# ── model ─────────────────────────────────────────────────────────────────────
|
||
|
||
print("Loading model and data …")
|
||
model = efficientnet_v2_s(weights=EfficientNet_V2_S_Weights.IMAGENET1K_V1)
|
||
model.classifier[1] = nn.Linear(model.classifier[1].in_features, len(CLASS_NAMES))
|
||
model.load_state_dict(torch.load(WEIGHTS_PATH, map_location=device, weights_only=True))
|
||
model.to(device).eval()
|
||
|
||
test_ds = WildForestAnimalsDataset(DATASET_DIR, "test", transform=_eval_tf)
|
||
train_ds = WildForestAnimalsDataset(DATASET_DIR, "train", transform=_eval_tf)
|
||
|
||
_norm = transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
||
_unnorm = transforms.Normalize(
|
||
mean=[-0.485 / 0.229, -0.456 / 0.224, -0.406 / 0.225],
|
||
std=[1 / 0.229, 1 / 0.224, 1 / 0.225],
|
||
)
|
||
score_cam = ScoreCAM(model=model, target_layers=[model.features[-1]])
|
||
lime_exp = lime_image.LimeImageExplainer(random_state=SEED)
|
||
|
||
|
||
def _to_display(t):
|
||
return torch.clamp(_unnorm(t), 0, 1).numpy().transpose(1, 2, 0)
|
||
|
||
|
||
def _lime_predict(images):
|
||
batch = torch.stack([
|
||
_norm(torch.tensor(im.transpose(2, 0, 1), dtype=torch.float32))
|
||
for im in images
|
||
]).to(device)
|
||
with torch.no_grad():
|
||
return torch.nn.functional.softmax(model(batch), dim=1).cpu().numpy()
|
||
|
||
|
||
print("Pre-computing training features …")
|
||
|
||
|
||
def _extract_features(ds, bs=64):
|
||
loader = DataLoader(ds, batch_size=bs, shuffle=False)
|
||
feats, labs = [], []
|
||
with torch.no_grad():
|
||
for imgs, lbls in loader:
|
||
x = torch.flatten(model.avgpool(model.features(imgs.to(device))), 1)
|
||
feats.append(x.cpu())
|
||
labs.extend(lbls.tolist())
|
||
return torch.nn.functional.normalize(torch.cat(feats), dim=1), labs
|
||
|
||
|
||
train_feats, train_labels = _extract_features(train_ds)
|
||
print("Ready.")
|
||
|
||
# ── XAI helpers ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _save_img(arr, path):
|
||
if arr.dtype != np.uint8:
|
||
arr = (np.clip(arr, 0, 1) * 255).astype(np.uint8)
|
||
Image.fromarray(arr).save(path)
|
||
|
||
|
||
def _forward(inp):
|
||
"""Single split forward pass returning probs, pred_idx, and neighbour query vector."""
|
||
with torch.no_grad():
|
||
pooled = model.avgpool(model.features(inp))
|
||
logits = model.classifier(torch.flatten(pooled, 1))
|
||
probs = torch.nn.functional.softmax(logits, dim=1).cpu().squeeze()
|
||
qf = torch.nn.functional.normalize(
|
||
torch.flatten(pooled, 1).cpu(), dim=1,
|
||
).squeeze()
|
||
return probs, probs.argmax().item(), qf
|
||
|
||
|
||
def compute_xai(det, pre=None):
|
||
"""Compute all XAI artefacts. `pre` is an optional dict with keys
|
||
inp, raw, probs, pred_idx, qf to skip the duplicate forward pass."""
|
||
out = XAI_DIR / det["id"]
|
||
if (out / "meta.json").exists():
|
||
return
|
||
out.mkdir(exist_ok=True)
|
||
|
||
if pre is not None:
|
||
inp, raw, probs, pred_idx, qf = (
|
||
pre["inp"], pre["raw"], pre["probs"], pre["pred_idx"], pre["qf"])
|
||
else:
|
||
idx = det["idx"]
|
||
tensor, _ = test_ds[idx]
|
||
img_path, _ = test_ds.samples[idx]
|
||
raw = np.array(
|
||
Image.open(img_path).convert("RGB").resize((224, 224)),
|
||
dtype=np.float64,
|
||
) / 255.0
|
||
inp = tensor.unsqueeze(0).to(device)
|
||
_save_img(raw, out / "original.png")
|
||
probs, pred_idx, qf = _forward(inp)
|
||
|
||
entropy = -(probs * torch.log(probs + 1e-12)).sum().item()
|
||
|
||
# ScoreCAM
|
||
hm = score_cam(input_tensor=inp, targets=None)
|
||
_save_img(
|
||
show_cam_on_image(raw.astype(np.float32), hm.squeeze(), use_rgb=True, image_weight=0.75),
|
||
out / "scorecam.png",
|
||
)
|
||
|
||
# LIME (500 samples)
|
||
expl = lime_exp.explain_instance(raw, _lime_predict, top_labels=2, hide_color=0, num_samples=500)
|
||
c1, c2 = expl.top_labels[0], expl.top_labels[1]
|
||
t1, m1 = expl.get_image_and_mask(c1, positive_only=False, num_features=10, hide_rest=False)
|
||
t2, m2 = expl.get_image_and_mask(c2, positive_only=False, num_features=10, hide_rest=False)
|
||
_save_img(mark_boundaries(t1, m1), out / "lime1.png")
|
||
_save_img(mark_boundaries(t2, m2), out / "lime2.png")
|
||
|
||
segs = expl.segments
|
||
w1, w2 = dict(expl.local_exp[c1]), dict(expl.local_exp[c2])
|
||
diff = np.zeros(segs.shape, dtype=np.float64)
|
||
for sid in np.unique(segs):
|
||
diff[segs == sid] = w1.get(sid, 0) - w2.get(sid, 0)
|
||
mx = max(np.abs(diff).max(), 1e-8)
|
||
diff /= mx
|
||
col = mpl_cm.RdBu_r((diff + 1) / 2)[:, :, :3]
|
||
_save_img((0.6 * raw + 0.4 * col).clip(0, 1), out / "contrastive.png")
|
||
|
||
# nearest neighbours — 2x2 composite with labels
|
||
sims, idxs = (train_feats @ qf).topk(4)
|
||
nbs = []
|
||
nb_pils = []
|
||
for ni, ns in zip(idxs, sims):
|
||
arr = _to_display(train_ds[ni.item()][0])
|
||
cls = CLASS_NAMES[train_labels[ni]]
|
||
sim = f"{ns:.3f}"
|
||
nbs.append({"cls": cls, "sim": sim})
|
||
pil = Image.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8))
|
||
draw = ImageDraw.Draw(pil)
|
||
label = f"{cls} ({sim})"
|
||
draw.rectangle([(0, pil.height - 22), (pil.width, pil.height)], fill=(0, 0, 0, 180))
|
||
draw.text((6, pil.height - 20), label, fill=(255, 255, 255))
|
||
nb_pils.append(pil)
|
||
w, h = nb_pils[0].size
|
||
grid = Image.new("RGB", (w * 2, h * 2))
|
||
for i, p in enumerate(nb_pils):
|
||
grid.paste(p, ((i % 2) * w, (i // 2) * h))
|
||
grid.save(out / "neighbours.png")
|
||
|
||
meta = {
|
||
"pred": CLASS_NAMES[pred_idx],
|
||
"conf": round(probs[pred_idx].item() * 100, 1),
|
||
"ppl": round(float(np.exp(entropy)), 2),
|
||
"probs": {CLASS_NAMES[i]: round(float(p), 4) for i, p in enumerate(probs)},
|
||
"lime1_cls": CLASS_NAMES[c1],
|
||
"lime2_cls": CLASS_NAMES[c2],
|
||
"contrast_leg": f"Blue = {CLASS_NAMES[c1]} | Red = {CLASS_NAMES[c2]}",
|
||
"nbs": nbs,
|
||
}
|
||
(out / "meta.json").write_text(json.dumps(meta))
|
||
|
||
|
||
# ── Flask app ─────────────────────────────────────────────────────────────────
|
||
|
||
app = Flask(__name__)
|
||
detections: list[dict] = []
|
||
_xai_events: dict[str, threading.Event] = {}
|
||
_xai_lock = threading.Lock()
|
||
|
||
|
||
def _precompute_xai(det, pre=None):
|
||
try:
|
||
with _xai_lock:
|
||
compute_xai(det, pre)
|
||
finally:
|
||
ev = _xai_events.get(det["id"])
|
||
if ev:
|
||
ev.set()
|
||
|
||
|
||
@app.route("/")
|
||
@app.route("/home")
|
||
def home():
|
||
return render_template_string(HOME_HTML, cameras=CAMERAS, class_names=CLASS_NAMES)
|
||
|
||
|
||
@app.route("/det/<det_id>")
|
||
def detail(det_id):
|
||
det = next((d for d in detections if d["id"] == det_id), None)
|
||
if det is None:
|
||
return "Detection not found", 404
|
||
return render_template_string(DETAIL_HTML, det=det, cameras=CAMERAS, class_names=CLASS_NAMES)
|
||
|
||
|
||
@app.route("/api/simulate", methods=["POST"])
|
||
def api_simulate():
|
||
idx = random.randint(0, len(test_ds) - 1)
|
||
cam_id = random.choice(list(CAMERAS.keys()))
|
||
tensor, _ = test_ds[idx]
|
||
inp = tensor.unsqueeze(0).to(device)
|
||
probs, pred_idx, qf = _forward(inp)
|
||
|
||
img_path, _ = test_ds.samples[idx]
|
||
raw = np.array(
|
||
Image.open(img_path).convert("RGB").resize((224, 224)), dtype=np.float64,
|
||
) / 255.0
|
||
|
||
det = {
|
||
"id": uuid.uuid4().hex[:8],
|
||
"idx": idx,
|
||
"cam": cam_id,
|
||
"cam_name": CAMERAS[cam_id]["name"],
|
||
"pred": CLASS_NAMES[pred_idx],
|
||
"conf": round(probs[pred_idx].item() * 100, 1),
|
||
"time": datetime.now().strftime("%H:%M:%S"),
|
||
"verified": False,
|
||
"manual": False,
|
||
}
|
||
detections.append(det)
|
||
out = XAI_DIR / det["id"]
|
||
out.mkdir(exist_ok=True)
|
||
_save_img(raw, out / "original.png")
|
||
|
||
pre = {"inp": inp, "raw": raw, "probs": probs, "pred_idx": pred_idx, "qf": qf}
|
||
ev = threading.Event()
|
||
_xai_events[det["id"]] = ev
|
||
threading.Thread(target=_precompute_xai, args=(det, pre), daemon=True).start()
|
||
return jsonify(det)
|
||
|
||
|
||
@app.route("/api/xai/<det_id>")
|
||
def api_xai(det_id):
|
||
det = next((d for d in detections if d["id"] == det_id), None)
|
||
if det is None:
|
||
return jsonify(error="not found"), 404
|
||
ev = _xai_events.get(det_id)
|
||
if ev:
|
||
ev.wait()
|
||
else:
|
||
compute_xai(det)
|
||
meta = json.loads((XAI_DIR / det_id / "meta.json").read_text())
|
||
base = f"/xai/{det_id}"
|
||
meta["urls"] = {
|
||
k: f"{base}/{k}.png"
|
||
for k in ["original", "scorecam", "lime1", "lime2", "contrastive",
|
||
"neighbours"]
|
||
}
|
||
return jsonify(meta)
|
||
|
||
|
||
@app.route("/cam/<cam_id>")
|
||
def camera(cam_id):
|
||
if cam_id not in CAMERAS:
|
||
return "Camera not found", 404
|
||
cam_dets = [d for d in reversed(detections) if d["cam"] == cam_id]
|
||
return render_template_string(CAM_HTML, cam_id=cam_id, cam=CAMERAS[cam_id], dets=cam_dets)
|
||
|
||
|
||
@app.route("/api/verify/<det_id>", methods=["POST"])
|
||
def api_verify(det_id):
|
||
det = next((d for d in detections if d["id"] == det_id), None)
|
||
if det is None:
|
||
return jsonify(error="not found"), 404
|
||
data = request.get_json()
|
||
if data.get("action") == "correct":
|
||
det["verified"] = True
|
||
elif data.get("action") == "wrong":
|
||
det["verified"] = True
|
||
det["manual"] = True
|
||
det["orig_pred"] = det["pred"]
|
||
det["orig_conf"] = det["conf"]
|
||
det["pred"] = data["true_class"]
|
||
det["conf"] = 100.0
|
||
return jsonify(det)
|
||
|
||
|
||
@app.route("/api/detections")
|
||
def api_detections():
|
||
return jsonify(detections)
|
||
|
||
|
||
@app.route("/map.jpg")
|
||
def serve_map():
|
||
return send_from_directory(".", "yellowstone-camping-map.jpg")
|
||
|
||
|
||
@app.route("/xai/<det_id>/<filename>")
|
||
def serve_xai(det_id, filename):
|
||
return send_from_directory(str(XAI_DIR / det_id), filename)
|
||
|
||
|
||
# ── HTML: home page ──────────────────────────────────────────────────────────
|
||
|
||
HOME_HTML = r"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Yellowstone — Wildlife Monitor</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:system-ui,-apple-system,sans-serif;overflow:hidden;height:100vh;background:#0f172a}
|
||
|
||
/* ── map ── */
|
||
#map-wrap{position:relative;height:100vh;width:auto;margin:0 auto;display:inline-block}
|
||
#map-outer{display:flex;justify-content:center;width:100vw;height:100vh;overflow:hidden}
|
||
#map-img{height:100vh;width:auto;display:block;user-select:none;-webkit-user-drag:none}
|
||
.cam-marker{
|
||
position:absolute;width:22px;height:22px;border-radius:50%;
|
||
background:#2563eb;border:3px solid white;
|
||
box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;
|
||
transform:translate(-50%,-50%);transition:all .3s;z-index:10;
|
||
text-decoration:none;
|
||
}
|
||
.cam-marker.active{background:#ef4444;animation:pulse 1.4s infinite}
|
||
.cam-label{
|
||
position:absolute;transform:translate(-50%,-100%);
|
||
background:rgba(0,0,0,.75);color:#fff;padding:3px 10px;border-radius:5px;
|
||
font-size:11px;white-space:nowrap;pointer-events:none;z-index:10;
|
||
backdrop-filter:blur(4px);
|
||
}
|
||
@keyframes pulse{
|
||
0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.45)}
|
||
50%{box-shadow:0 0 0 14px rgba(239,68,68,0)}
|
||
}
|
||
|
||
.wolf-warn{
|
||
position:absolute;width:120px;height:120px;border-radius:50%;
|
||
border:3px solid rgba(239,68,68,.7);
|
||
background:rgba(239,68,68,.1);
|
||
transform:translate(-50%,-50%);z-index:5;pointer-events:none;
|
||
animation:wolfPulse 2s ease-in-out infinite;
|
||
transition:opacity 1.5s ease;
|
||
}
|
||
.wolf-warn.fade{animation:none;opacity:0}
|
||
.wolf-warn-label{
|
||
position:absolute;transform:translate(-50%,64px);z-index:6;
|
||
color:#ef4444;font-size:11px;font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.5px;pointer-events:none;white-space:nowrap;
|
||
text-shadow:0 1px 4px rgba(0,0,0,.6);
|
||
transition:opacity 1.5s ease;
|
||
}
|
||
.wolf-warn-label.fade{opacity:0}
|
||
@keyframes wolfPulse{
|
||
0%,100%{transform:translate(-50%,-50%) scale(1);opacity:.8}
|
||
50%{transform:translate(-50%,-50%) scale(1.15);opacity:.5}
|
||
}
|
||
|
||
/* ── title ── */
|
||
#title{
|
||
position:fixed;top:16px;left:16px;z-index:50;
|
||
background:rgba(15,23,42,.88);backdrop-filter:blur(10px);
|
||
color:#fff;padding:14px 22px;border-radius:12px;
|
||
}
|
||
#title h1{font-size:17px;font-weight:700}
|
||
#title p{font-size:11px;opacity:.65;margin-top:2px}
|
||
|
||
/* ── toast ── */
|
||
#toast{
|
||
position:fixed;top:20px;left:50%;z-index:200;
|
||
transform:translateX(-50%) translateY(-120px);
|
||
background:linear-gradient(135deg,#16a34a,#15803d);color:#fff;
|
||
padding:14px 26px;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.35);
|
||
font-size:14px;transition:transform .4s ease;white-space:nowrap;
|
||
backdrop-filter:blur(8px);
|
||
}
|
||
#toast.show{transform:translateX(-50%) translateY(0)}
|
||
|
||
/* ── sidebar ── */
|
||
#sidebar-toggle{
|
||
position:fixed;top:16px;right:16px;z-index:101;
|
||
background:rgba(15,23,42,.88);backdrop-filter:blur(10px);color:#fff;border:none;
|
||
padding:10px 18px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;
|
||
transition:right .3s ease;
|
||
}
|
||
#sidebar-toggle.shifted{right:376px}
|
||
|
||
#sidebar{
|
||
position:fixed;top:0;right:0;width:360px;height:100vh;
|
||
background:rgba(15,23,42,.96);backdrop-filter:blur(14px);color:#fff;
|
||
transform:translateX(100%);transition:transform .3s ease;z-index:100;
|
||
display:flex;flex-direction:column;
|
||
}
|
||
#sidebar.open{transform:translateX(0)}
|
||
|
||
.sb-header{padding:24px 20px;border-bottom:1px solid rgba(255,255,255,.08);text-align:center}
|
||
.sb-header h2{font-size:13px;opacity:.55;font-weight:400;margin-bottom:2px}
|
||
.sb-count{font-size:34px;font-weight:700}
|
||
|
||
.sb-chart{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||
.sb-chart h3{font-size:11px;opacity:.45;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px}
|
||
.bar-row{display:flex;align-items:center;margin-bottom:5px;font-size:12px;cursor:pointer;
|
||
padding:3px 0;border-radius:5px;transition:background .15s}
|
||
.bar-row:hover{background:rgba(255,255,255,.06)}
|
||
.bar-row.selected{background:rgba(59,130,246,.15)}
|
||
.bar-row.selected .bar-label{opacity:1;color:#60a5fa}
|
||
.bar-row.selected .bar-fill{background:#f59e0b}
|
||
.bar-label{width:58px;text-align:right;padding-right:8px;opacity:.7;transition:all .15s}
|
||
.bar-fill{height:16px;background:#3b82f6;border-radius:3px;transition:width .5s ease,background .2s;min-width:2px}
|
||
.bar-num{padding-left:6px;opacity:.45;font-size:11px}
|
||
|
||
.heatmap-dot{
|
||
position:absolute;border-radius:50%;pointer-events:none;z-index:4;
|
||
transform:translate(-50%,-50%);
|
||
background:radial-gradient(circle,rgba(251,191,36,.75) 0%,rgba(251,191,36,.35) 50%,rgba(251,191,36,.08) 80%,transparent 100%);
|
||
border:2px solid rgba(251,191,36,.6);
|
||
animation:heatFade .5s ease;
|
||
}
|
||
.heatmap-label{
|
||
position:absolute;transform:translate(-50%,0);z-index:6;pointer-events:none;
|
||
color:#fbbf24;font-size:12px;font-weight:700;white-space:nowrap;
|
||
text-shadow:0 1px 6px rgba(0,0,0,.9),0 0 3px rgba(0,0,0,.6);
|
||
}
|
||
@keyframes heatFade{from{opacity:0;transform:translate(-50%,-50%) scale(.5)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}
|
||
|
||
.sb-filters{padding:10px 20px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;gap:6px}
|
||
.sb-filter{
|
||
flex:1;padding:6px 0;border:none;border-radius:6px;cursor:pointer;
|
||
font-size:11px;font-weight:600;text-align:center;
|
||
background:rgba(255,255,255,.06);color:rgba(255,255,255,.5);transition:all .15s;
|
||
}
|
||
.sb-filter:hover{background:rgba(255,255,255,.1)}
|
||
.sb-filter.active{background:#3b82f6;color:#fff}
|
||
|
||
.sb-list{flex:1;overflow-y:auto;padding:6px 0}
|
||
.det-link{
|
||
display:flex;align-items:center;padding:11px 20px;gap:12px;
|
||
border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer;
|
||
text-decoration:none;color:inherit;transition:background .12s;
|
||
}
|
||
.det-link:hover{background:rgba(255,255,255,.07)}
|
||
.det-icon{font-size:22px;flex-shrink:0}
|
||
.det-info{flex:1}
|
||
.det-species{font-weight:600;font-size:13px}
|
||
.det-meta{font-size:11px;opacity:.45;margin-top:1px}
|
||
.det-arrow{opacity:.3;font-size:18px}
|
||
.det-badge{
|
||
font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;flex-shrink:0;
|
||
}
|
||
.det-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
|
||
.det-badge.manual{background:rgba(234,179,8,.2);color:#eab308}
|
||
.det-badge.unverified{background:rgba(148,163,184,.15);color:#64748b}
|
||
.empty-state{padding:40px 20px;text-align:center;opacity:.4;font-size:13px;line-height:1.6}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="title"><h1>Yellowstone National Park</h1><p>Wildlife Camera Monitoring</p></div>
|
||
|
||
<!-- map -->
|
||
<div id="map-outer">
|
||
<div id="map-wrap">
|
||
<img id="map-img" src="/map.jpg" alt="Park map" draggable="false">
|
||
{% for cid, c in cameras.items() %}
|
||
<a href="/cam/{{ cid }}" class="cam-marker" id="mk-{{ cid }}" style="left:{{ c.px }}%;top:{{ c.py }}%"></a>
|
||
<div class="cam-label" style="left:{{ c.px }}%;top:calc({{ c.py }}% - 18px)">{{ cid }} · {{ c.name }}</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
<button id="sidebar-toggle" onclick="toggleSidebar()" class="shifted">◀ Close</button>
|
||
|
||
<!-- sidebar -->
|
||
<div id="sidebar" class="open">
|
||
<div class="sb-header">
|
||
<h2>Detections Today</h2>
|
||
<div class="sb-count" id="sb-count">0</div>
|
||
</div>
|
||
<div class="sb-chart">
|
||
<h3>Detections by Species</h3>
|
||
<div id="chart"></div>
|
||
</div>
|
||
<div class="sb-filters">
|
||
<button class="sb-filter active" data-filter="all" onclick="setFilter('all')">All</button>
|
||
<button class="sb-filter" data-filter="unverified" onclick="setFilter('unverified')">Unverified</button>
|
||
<button class="sb-filter" data-filter="verified" onclick="setFilter('verified')">Verified</button>
|
||
<button class="sb-filter" data-filter="corrected" onclick="setFilter('corrected')">Corrected</button>
|
||
</div>
|
||
<div class="sb-list" id="sb-list">
|
||
<div class="empty-state">No detections yet.<br>Detections appear automatically.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const CN = {{ class_names | tojson }};
|
||
const CAMS = {{ cameras | tojson }};
|
||
const ICONS = {bear:'\u{1F43B}',deer:'\u{1F98C}',fox:'\u{1F98A}',hare:'\u{1F407}',
|
||
moose:'\u{1F98C}',person:'\u{1F9D1}',wolf:'\u{1F43A}'};
|
||
let dets=[], sbOpen=true, activeFilter='all', selectedSpecies=null;
|
||
|
||
function toggleSidebar(){
|
||
sbOpen=!sbOpen;
|
||
document.getElementById('sidebar').classList.toggle('open',sbOpen);
|
||
const b=document.getElementById('sidebar-toggle');
|
||
b.classList.toggle('shifted',sbOpen);
|
||
b.innerHTML=sbOpen?'\u25C0 Close':'Detections \u25B6';
|
||
}
|
||
|
||
function toast(msg){
|
||
const t=document.getElementById('toast');
|
||
t.innerHTML=msg; t.classList.add('show');
|
||
setTimeout(()=>t.classList.remove('show'),3500);
|
||
}
|
||
|
||
function renderChart(){
|
||
const counts={};CN.forEach(c=>counts[c]=0);
|
||
dets.forEach(d=>counts[d.pred]=(counts[d.pred]||0)+1);
|
||
const mx=Math.max(...Object.values(counts),1);
|
||
let h='';
|
||
for(const[sp,n]of Object.entries(counts)){
|
||
const pct=(n/mx)*100;
|
||
const sel=sp===selectedSpecies?' selected':'';
|
||
h+=`<div class="bar-row${sel}" onclick="toggleHeatmap('${sp}')"><div class="bar-label">${sp}</div>
|
||
<div class="bar-fill" style="width:${pct}%;${n?'':'opacity:.25'}"></div>
|
||
<div class="bar-num">${n}</div></div>`;
|
||
}
|
||
document.getElementById('chart').innerHTML=h;
|
||
}
|
||
|
||
function toggleHeatmap(species){
|
||
selectedSpecies = selectedSpecies===species ? null : species;
|
||
renderChart();
|
||
renderHeatmap();
|
||
}
|
||
|
||
function renderHeatmap(){
|
||
document.querySelectorAll('.heatmap-dot,.heatmap-label').forEach(e=>e.remove());
|
||
if(!selectedSpecies) return;
|
||
const wrap=document.getElementById('map-wrap');
|
||
const camCounts={};
|
||
for(const cid in CAMS) camCounts[cid]=0;
|
||
dets.filter(d=>d.pred===selectedSpecies).forEach(d=>{camCounts[d.cam]=(camCounts[d.cam]||0)+1;});
|
||
const mx=Math.max(...Object.values(camCounts),1);
|
||
for(const[cid,n]of Object.entries(camCounts)){
|
||
if(!n) continue;
|
||
const c=CAMS[cid];
|
||
const size=60+Math.round((n/mx)*100);
|
||
const dot=document.createElement('div');
|
||
dot.className='heatmap-dot';
|
||
dot.style.left=c.px+'%';dot.style.top=c.py+'%';
|
||
dot.style.width=size+'px';dot.style.height=size+'px';
|
||
wrap.appendChild(dot);
|
||
const lbl=document.createElement('div');
|
||
lbl.className='heatmap-label';
|
||
lbl.style.left=c.px+'%';
|
||
lbl.style.top=`calc(${c.py}% + ${size/2+4}px)`;
|
||
lbl.textContent=`${n}\u00D7 ${selectedSpecies}`;
|
||
wrap.appendChild(lbl);
|
||
}
|
||
}
|
||
|
||
function setFilter(f){
|
||
activeFilter=f;
|
||
document.querySelectorAll('.sb-filter').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));
|
||
renderList();
|
||
}
|
||
|
||
function renderList(){
|
||
document.getElementById('sb-count').textContent=dets.length;
|
||
let filtered=[...dets];
|
||
if(activeFilter==='unverified') filtered=filtered.filter(d=>!d.verified);
|
||
else if(activeFilter==='verified') filtered=filtered.filter(d=>d.verified&&!d.manual);
|
||
else if(activeFilter==='corrected') filtered=filtered.filter(d=>d.verified&&d.manual);
|
||
if(!filtered.length){
|
||
document.getElementById('sb-list').innerHTML=
|
||
`<div class="empty-state">${dets.length?'No detections match this filter.':'No detections yet.<br>Detections appear automatically.'}</div>`;
|
||
return;
|
||
}
|
||
let h='';
|
||
for(const d of[...filtered].reverse()){
|
||
const ic=ICONS[d.pred]||'';
|
||
const badge=d.verified
|
||
?(d.manual?'<span class="det-badge manual">corrected</span>'
|
||
:'<span class="det-badge verified">verified</span>')
|
||
:'<span class="det-badge unverified">unverified</span>';
|
||
h+=`<a class="det-link" href="/det/${d.id}">
|
||
<div class="det-icon">${ic}</div>
|
||
<div class="det-info">
|
||
<div class="det-species">${d.pred[0].toUpperCase()+d.pred.slice(1)}
|
||
<span style="opacity:.45;font-weight:400"> \u2014 ${d.conf.toFixed(0)}%</span></div>
|
||
<div class="det-meta">${d.cam} \u00B7 ${d.cam_name} \u00B7 ${d.time}</div>
|
||
</div>
|
||
${badge}
|
||
<div class="det-arrow">\u203A</div></a>`;
|
||
}
|
||
document.getElementById('sb-list').innerHTML=h;
|
||
}
|
||
|
||
function flashCam(cid){
|
||
document.querySelectorAll('.cam-marker').forEach(m=>m.classList.remove('active'));
|
||
const mk=document.getElementById('mk-'+cid);
|
||
if(mk){mk.classList.add('active');setTimeout(()=>mk.classList.remove('active'),6000);}
|
||
}
|
||
|
||
function showDangerWarning(cid,species){
|
||
const c=CAMS[cid]; if(!c)return;
|
||
const wrap=document.getElementById('map-wrap');
|
||
const key=species+'-'+cid;
|
||
const old=document.getElementById('warn-'+key);
|
||
if(old)old.remove();
|
||
const oldLbl=document.getElementById('warn-lbl-'+key);
|
||
if(oldLbl)oldLbl.remove();
|
||
const circle=document.createElement('div');
|
||
circle.className='wolf-warn';circle.id='warn-'+key;
|
||
circle.style.left=c.px+'%';circle.style.top=c.py+'%';
|
||
const lbl=document.createElement('div');
|
||
lbl.className='wolf-warn-label';lbl.id='warn-lbl-'+key;
|
||
lbl.style.left=c.px+'%';lbl.style.top=c.py+'%';
|
||
lbl.textContent='\u26A0 '+species[0].toUpperCase()+species.slice(1)+' detected';
|
||
wrap.appendChild(circle);wrap.appendChild(lbl);
|
||
setTimeout(()=>{circle.classList.add('fade');lbl.classList.add('fade');},10000);
|
||
setTimeout(()=>{circle.remove();lbl.remove();},11500);
|
||
}
|
||
|
||
let simulating=false;
|
||
async function simulate(){
|
||
if(simulating)return;
|
||
simulating=true;
|
||
try{
|
||
const r=await fetch('/api/simulate',{method:'POST'});
|
||
const d=await r.json();
|
||
dets.push(d);
|
||
flashCam(d.cam);
|
||
if(d.pred==='wolf'||d.pred==='bear') showDangerWarning(d.cam,d.pred);
|
||
toast(`<b>${ICONS[d.pred]||''} ${d.pred[0].toUpperCase()+d.pred.slice(1)}</b> detected at ${d.cam} (${d.cam_name}) \u2014 ${d.conf.toFixed(0)}%`);
|
||
renderList();renderChart();renderHeatmap();
|
||
}catch(e){}
|
||
simulating=false;
|
||
}
|
||
|
||
function autoSimLoop(){
|
||
const delay=5000+Math.random()*5000;
|
||
setTimeout(async()=>{await simulate();autoSimLoop();},delay);
|
||
}
|
||
|
||
(async function init(){
|
||
try{
|
||
const r=await fetch('/api/detections');
|
||
const existing=await r.json();
|
||
dets=existing;
|
||
}catch(e){}
|
||
renderChart();renderList();
|
||
autoSimLoop();
|
||
})();
|
||
</script>
|
||
</body></html>"""
|
||
|
||
# ── HTML: detail page ─────────────────────────────────────────────────────────
|
||
|
||
DETAIL_HTML = r"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Detection {{ det.id }} — Yellowstone</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.5}
|
||
|
||
.topbar{
|
||
position:sticky;top:0;z-index:50;
|
||
background:rgba(15,23,42,.92);backdrop-filter:blur(12px);
|
||
padding:14px 24px;display:flex;align-items:center;gap:16px;
|
||
border-bottom:1px solid rgba(255,255,255,.06);
|
||
}
|
||
.back{
|
||
background:rgba(255,255,255,.08);color:#e2e8f0;border:none;
|
||
padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;
|
||
transition:background .15s;text-decoration:none;
|
||
}
|
||
.back:hover{background:rgba(255,255,255,.14)}
|
||
.topbar-info{flex:1}
|
||
.topbar-title{font-size:15px;font-weight:700}
|
||
.topbar-sub{font-size:12px;opacity:.55;margin-top:1px}
|
||
|
||
.container{max-width:1200px;margin:0 auto;padding:28px 24px 60px}
|
||
|
||
/* verification */
|
||
.verify-bar{
|
||
margin-bottom:24px;padding:16px 22px;border-radius:12px;
|
||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
|
||
display:flex;align-items:center;gap:14px;flex-wrap:wrap;
|
||
}
|
||
.verify-bar.done{border-color:rgba(34,197,94,.25)}
|
||
.vb-status{flex:1;font-size:14px}
|
||
.vb-btn{
|
||
padding:8px 18px;border-radius:8px;border:none;cursor:pointer;
|
||
font-size:13px;font-weight:600;transition:opacity .15s;
|
||
}
|
||
.vb-btn:hover{opacity:.85}
|
||
.vb-correct{background:#22c55e;color:#fff}
|
||
.vb-wrong{background:#ef4444;color:#fff}
|
||
.vb-submit{background:#3b82f6;color:#fff}
|
||
.vb-cancel{background:rgba(255,255,255,.1);color:#e2e8f0}
|
||
.vb-select{
|
||
padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.15);
|
||
background:rgba(255,255,255,.08);color:#e2e8f0;font-size:13px;
|
||
}
|
||
.vb-badge{
|
||
display:inline-block;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;
|
||
}
|
||
.vb-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
|
||
.vb-badge.manual{background:rgba(234,179,8,.2);color:#eab308}
|
||
|
||
/* two-column layout */
|
||
.det-layout{display:grid;grid-template-columns:1fr 340px;gap:24px;align-items:start}
|
||
@media(max-width:860px){.det-layout{grid-template-columns:1fr}}
|
||
|
||
/* viewer */
|
||
.viewer{
|
||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
|
||
border-radius:14px;overflow:hidden;
|
||
}
|
||
.viewer-head{
|
||
display:flex;align-items:center;padding:14px 18px;gap:12px;
|
||
border-bottom:1px solid rgba(255,255,255,.06);
|
||
}
|
||
.nav-btn{
|
||
width:38px;height:38px;border-radius:8px;border:none;cursor:pointer;
|
||
background:rgba(255,255,255,.08);color:#e2e8f0;font-size:18px;
|
||
display:flex;align-items:center;justify-content:center;transition:background .15s;flex-shrink:0;
|
||
}
|
||
.nav-btn:hover{background:rgba(255,255,255,.16)}
|
||
.slide-title{flex:1;text-align:center;font-size:15px;font-weight:700}
|
||
.slide-counter{font-size:12px;opacity:.4;font-weight:400}
|
||
.viewer-img{position:relative;background:#000;min-height:300px;display:flex;align-items:center;justify-content:center}
|
||
.viewer-img img{width:100%;display:block}
|
||
.slide-cap{padding:12px 18px;font-size:13px;opacity:.6;min-height:42px}
|
||
.slide-dots{display:flex;gap:7px;justify-content:center;padding:0 18px 16px}
|
||
.dot{
|
||
width:9px;height:9px;border-radius:50%;border:none;cursor:pointer;
|
||
background:rgba(255,255,255,.15);transition:all .2s;padding:0;
|
||
}
|
||
.dot.active{background:#3b82f6;transform:scale(1.25)}
|
||
.dot:hover{background:rgba(255,255,255,.35)}
|
||
|
||
/* chart sidebar */
|
||
.chart-panel{
|
||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
|
||
border-radius:14px;overflow:hidden;position:sticky;top:80px;
|
||
}
|
||
.chart-panel h3{
|
||
padding:16px 18px 0;font-size:12px;opacity:.45;text-transform:uppercase;letter-spacing:.4px;
|
||
}
|
||
.prob-chart{padding:14px 18px 6px}
|
||
.prob-row{display:flex;align-items:center;margin-bottom:6px;font-size:12px}
|
||
.prob-label{width:54px;text-align:right;padding-right:10px;opacity:.7;text-transform:capitalize}
|
||
.prob-track{flex:1;height:20px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;position:relative}
|
||
.prob-fill{height:100%;border-radius:4px;transition:width .6s ease;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;min-width:0}
|
||
.prob-fill span{font-size:10px;font-weight:700;color:#fff;opacity:.9}
|
||
.prob-fill.top{background:linear-gradient(90deg,#e8832a,#f59e0b)}
|
||
.prob-fill.other{background:linear-gradient(90deg,#3b82f6,#60a5fa)}
|
||
.pred-info{padding:0 18px 16px;font-size:13px;line-height:1.7}
|
||
.pred-info b{color:#fff}
|
||
.pred-info .tag{
|
||
display:inline-block;padding:2px 8px;border-radius:5px;font-size:12px;font-weight:600;
|
||
margin-right:4px;
|
||
}
|
||
.tag-pred{background:rgba(59,130,246,.15);color:#60a5fa}
|
||
|
||
.loading{
|
||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
padding:80px 0;gap:16px;
|
||
}
|
||
.spinner{
|
||
width:36px;height:36px;border:3px solid rgba(255,255,255,.12);
|
||
border-top-color:#3b82f6;border-radius:50%;animation:spin .7s linear infinite;
|
||
}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
#xai{display:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<a class="back" href="/">← Dashboard</a>
|
||
<div class="topbar-info">
|
||
<div class="topbar-title">Detection {{ det.id }}</div>
|
||
<div class="topbar-sub">
|
||
{{ det.cam }} · {{ cameras[det.cam].name }} · {{ det.time }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
|
||
<!-- verification -->
|
||
<div class="verify-bar {{ 'done' if det.verified else '' }}" id="verify-bar">
|
||
{% if det.verified %}
|
||
{% if det.manual %}
|
||
<div class="vb-status"><span class="vb-badge manual">Manually corrected</span>
|
||
Model predicted <b>{{ det.orig_pred }}</b> — ranger corrected to <b>{{ det.pred }}</b></div>
|
||
{% else %}
|
||
<div class="vb-status"><span class="vb-badge verified">Verified correct</span>
|
||
A ranger confirmed this detection is accurate.</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="vb-status" id="vb-prompt">Is this detection correct?</div>
|
||
<div id="vb-actions">
|
||
<button class="vb-btn vb-correct" onclick="verifyCorrect()">Correct</button>
|
||
<button class="vb-btn vb-wrong" onclick="showCorrectionForm()">Wrong</button>
|
||
</div>
|
||
<div id="vb-form" style="display:none">
|
||
<select class="vb-select" id="vb-class">
|
||
{% for c in class_names %}
|
||
<option value="{{ c }}">{{ c }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<button class="vb-btn vb-submit" onclick="submitCorrection()">Submit</button>
|
||
<button class="vb-btn vb-cancel" onclick="cancelCorrection()">Cancel</button>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="loader" class="loading">
|
||
<div class="spinner"></div>
|
||
<div style="opacity:.5;font-size:14px">Computing explanations …</div>
|
||
</div>
|
||
|
||
<div id="xai">
|
||
<div class="det-layout">
|
||
<!-- image viewer / carousel -->
|
||
<div class="viewer">
|
||
<div class="viewer-head">
|
||
<button class="nav-btn" onclick="prev()">‹</button>
|
||
<div class="slide-title">
|
||
<span id="slide-title">Loading…</span>
|
||
<span class="slide-counter" id="slide-counter"></span>
|
||
</div>
|
||
<button class="nav-btn" onclick="next()">›</button>
|
||
</div>
|
||
<div class="viewer-img"><img id="slide-img" alt=""></div>
|
||
<div class="slide-cap" id="slide-cap"></div>
|
||
<div class="slide-dots" id="slide-dots"></div>
|
||
</div>
|
||
|
||
<!-- probability chart (always visible) -->
|
||
<div class="chart-panel">
|
||
<h3>Class Probabilities</h3>
|
||
<div class="prob-chart" id="prob-chart"></div>
|
||
<div class="pred-info" id="pred-info"></div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /xai -->
|
||
</div><!-- /container -->
|
||
|
||
<script>
|
||
let slides=[], cur=0;
|
||
|
||
function render(){
|
||
const s=slides[cur];
|
||
document.getElementById('slide-img').src=s.src;
|
||
document.getElementById('slide-title').textContent=s.title;
|
||
document.getElementById('slide-cap').textContent=s.cap;
|
||
document.getElementById('slide-counter').textContent=`${cur+1} / ${slides.length}`;
|
||
document.querySelectorAll('.dot').forEach((d,i)=>d.classList.toggle('active',i===cur));
|
||
}
|
||
function prev(){cur=(cur-1+slides.length)%slides.length;render();}
|
||
function next(){cur=(cur+1)%slides.length;render();}
|
||
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='ArrowLeft')prev();
|
||
else if(e.key==='ArrowRight')next();
|
||
});
|
||
|
||
(async function(){
|
||
const r = await fetch('/api/xai/{{ det.id }}');
|
||
const d = await r.json();
|
||
const u = d.urls;
|
||
|
||
slides=[
|
||
{src:u.original, title:'Original Image',
|
||
cap:`Predicted: ${d.pred} (${d.conf}% confidence, perplexity ${d.ppl})`},
|
||
{src:u.scorecam, title:'ScoreCAM',
|
||
cap:'Highlights image regions the model attends to for its prediction.'},
|
||
{src:u.lime1, title:'LIME — '+d.lime1_cls,
|
||
cap:'Green superpixels support this class; red superpixels oppose it.'},
|
||
{src:u.lime2, title:'LIME — '+d.lime2_cls,
|
||
cap:'Green superpixels support this class; red superpixels oppose it.'},
|
||
{src:u.contrastive, title:'Contrastive Explanation',
|
||
cap:d.contrast_leg},
|
||
{src:u.neighbours, title:'Nearest Training Neighbours',
|
||
cap:d.nbs.map((nb,i)=>`#${i+1}: ${nb.cls} (sim ${nb.sim})`).join(' | ')},
|
||
];
|
||
|
||
let dots='';
|
||
slides.forEach((_,i)=>{dots+=`<button class="dot${i===0?' active':''}" onclick="cur=${i};render()"></button>`;});
|
||
document.getElementById('slide-dots').innerHTML=dots;
|
||
|
||
const probs=d.probs, mx=Math.max(...Object.values(probs),0.01);
|
||
let ch='';
|
||
for(const[cls,p] of Object.entries(probs)){
|
||
const pct=(p/mx)*100, isTop=cls===d.pred;
|
||
const lbl=p>=0.05?`<span>${(p*100).toFixed(1)}%</span>`:'';
|
||
ch+=`<div class="prob-row"><div class="prob-label">${cls}</div>
|
||
<div class="prob-track"><div class="prob-fill ${isTop?'top':'other'}" style="width:${pct}%">${lbl}</div></div></div>`;
|
||
}
|
||
document.getElementById('prob-chart').innerHTML=ch;
|
||
document.getElementById('pred-info').innerHTML=
|
||
`<span class="tag tag-pred">${d.pred}</span><br>`+
|
||
`Confidence <b>${d.conf}%</b> · Perplexity <b>${d.ppl}</b>`;
|
||
|
||
document.getElementById('loader').style.display='none';
|
||
document.getElementById('xai').style.display='block';
|
||
render();
|
||
})();
|
||
|
||
async function verifyCorrect(){
|
||
await fetch('/api/verify/{{ det.id }}',{
|
||
method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({action:'correct'})
|
||
});
|
||
const bar=document.getElementById('verify-bar');
|
||
bar.classList.add('done');
|
||
bar.innerHTML='<div class="vb-status"><span class="vb-badge verified">Verified correct</span> A ranger confirmed this detection is accurate.</div>';
|
||
}
|
||
|
||
function showCorrectionForm(){
|
||
document.getElementById('vb-actions').style.display='none';
|
||
document.getElementById('vb-form').style.display='flex';
|
||
document.getElementById('vb-prompt').textContent='Select the correct species:';
|
||
}
|
||
|
||
function cancelCorrection(){
|
||
document.getElementById('vb-actions').style.display='';
|
||
document.getElementById('vb-form').style.display='none';
|
||
document.getElementById('vb-prompt').textContent='Is this detection correct?';
|
||
}
|
||
|
||
async function submitCorrection(){
|
||
const cls=document.getElementById('vb-class').value;
|
||
await fetch('/api/verify/{{ det.id }}',{
|
||
method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({action:'wrong',true_class:cls})
|
||
});
|
||
const bar=document.getElementById('verify-bar');
|
||
bar.classList.add('done');
|
||
bar.innerHTML=`<div class="vb-status"><span class="vb-badge manual">Manually corrected</span> Model predicted <b>{{ det.pred }}</b> \u2014 ranger corrected to <b>${cls}</b></div>`;
|
||
}
|
||
</script>
|
||
</body></html>"""
|
||
|
||
|
||
# ── HTML: camera feed page ────────────────────────────────────────────────────
|
||
|
||
CAM_HTML = r"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{{ cam_id }} — {{ cam.name }}</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.5}
|
||
|
||
.topbar{
|
||
position:sticky;top:0;z-index:50;
|
||
background:rgba(15,23,42,.92);backdrop-filter:blur(12px);
|
||
padding:14px 24px;display:flex;align-items:center;gap:16px;
|
||
border-bottom:1px solid rgba(255,255,255,.06);
|
||
}
|
||
.back{
|
||
background:rgba(255,255,255,.08);color:#e2e8f0;border:none;
|
||
padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;
|
||
transition:background .15s;text-decoration:none;
|
||
}
|
||
.back:hover{background:rgba(255,255,255,.14)}
|
||
.topbar-info{flex:1}
|
||
.topbar-title{font-size:17px;font-weight:700}
|
||
.topbar-sub{font-size:12px;opacity:.55;margin-top:2px}
|
||
|
||
.container{max-width:1200px;margin:0 auto;padding:28px 24px 60px}
|
||
|
||
.grid{
|
||
display:grid;
|
||
grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
|
||
gap:16px;
|
||
}
|
||
|
||
.card{
|
||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
|
||
border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;
|
||
transition:transform .15s,box-shadow .15s;
|
||
}
|
||
.card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.3)}
|
||
.card img{width:100%;aspect-ratio:1;object-fit:cover;display:block}
|
||
.card-body{padding:10px 14px}
|
||
.card-species{font-weight:600;font-size:14px}
|
||
.card-conf{font-size:12px;opacity:.5;margin-top:1px}
|
||
.card-time{font-size:11px;opacity:.35;margin-top:3px}
|
||
.card-badge{
|
||
display:inline-block;font-size:10px;padding:2px 6px;border-radius:4px;
|
||
font-weight:600;margin-top:4px;
|
||
}
|
||
.card-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
|
||
.card-badge.manual{background:rgba(234,179,8,.2);color:#eab308}
|
||
.card-badge.unverified{background:rgba(148,163,184,.15);color:#64748b}
|
||
|
||
.empty{text-align:center;padding:60px 20px;opacity:.4;font-size:14px;line-height:1.6}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<a class="back" href="/">← Dashboard</a>
|
||
<div class="topbar-info">
|
||
<div class="topbar-title">{{ cam_id }} — {{ cam.name }}</div>
|
||
<div class="topbar-sub">{{ cam.desc }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
{% if dets %}
|
||
<div class="grid">
|
||
{% for d in dets %}
|
||
<a class="card" href="/det/{{ d.id }}">
|
||
<img src="/xai/{{ d.id }}/original.png" alt="{{ d.pred }}">
|
||
<div class="card-body">
|
||
<div class="card-species">{{ d.pred | capitalize }}</div>
|
||
<div class="card-conf">{{ d.conf }}% confidence</div>
|
||
<div class="card-time">{{ d.time }}</div>
|
||
{% if d.verified %}
|
||
{% if d.manual %}
|
||
<div class="card-badge manual">corrected</div>
|
||
{% else %}
|
||
<div class="card-badge verified">verified</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="card-badge unverified">unverified</div>
|
||
{% endif %}
|
||
</div>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty">No detections from this camera yet.<br>Detections will appear here automatically.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
</body></html>"""
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=True, port=5000)
|