1204 lines
44 KiB
Python
1204 lines
44 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,
|
|
class_names=CLASS_NAMES,
|
|
)
|
|
|
|
|
|
@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}
|
|
|
|
.cam-chart-panel{
|
|
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
|
|
border-radius:14px;padding:16px 20px;margin-bottom:24px;
|
|
}
|
|
.cam-chart-panel h3{
|
|
font-size:11px;opacity:.45;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px;
|
|
}
|
|
.bar-row{display:flex;align-items:center;margin-bottom:5px;font-size:12px;padding:3px 0;border-radius:5px}
|
|
.bar-label{width:58px;text-align:right;padding-right:8px;opacity:.7}
|
|
.bar-fill{height:16px;background:#3b82f6;border-radius:3px;transition:width .5s ease;min-width:2px}
|
|
.bar-num{padding-left:6px;opacity:.45;font-size:11px}
|
|
</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">
|
|
<div class="cam-chart-panel">
|
|
<h3>Detections by Species (this camera)</h3>
|
|
<div id="cam-chart-bars"></div>
|
|
</div>
|
|
{% 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>
|
|
|
|
<script>
|
|
const CAM_ID={{ cam_id | tojson }};
|
|
const CN={{ class_names | tojson }};
|
|
const INITIAL={{ dets | tojson }};
|
|
function renderCamChart(camDets){
|
|
const counts={};
|
|
CN.forEach(c=>{counts[c]=0});
|
|
camDets.forEach(d=>{
|
|
const p=d.pred;
|
|
if(Object.prototype.hasOwnProperty.call(counts,p)) counts[p]++;
|
|
});
|
|
const mx=Math.max(...Object.values(counts),1);
|
|
let h='';
|
|
for(const sp of CN){
|
|
const n=counts[sp];
|
|
const pct=(n/mx)*100;
|
|
h+=`<div class="bar-row"><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('cam-chart-bars').innerHTML=h;
|
|
}
|
|
renderCamChart(INITIAL);
|
|
setInterval(async()=>{
|
|
try{
|
|
const r=await fetch('/api/detections');
|
|
const all=await r.json();
|
|
renderCamChart(all.filter(d=>d.cam===CAM_ID));
|
|
}catch(e){}
|
|
},3000);
|
|
</script>
|
|
</body></html>"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(debug=True, port=5000)
|