Wildlife-Detection/dashboard.py
2026-03-19 13:19:24 +01:00

1150 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Wildlife Monitoring Dashboard — Nationaal Park De Hoge Veluwe
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": "Hubertus Trail", "px": 50, "py": 15,
"desc": "Northern forest near Jachthuis Sint Hubertus"},
"CAM-02": {"name": "Otterlo Gate", "px": 25, "py": 38,
"desc": "Western entrance, deciduous woodland"},
"CAM-03": {"name": "Kröller-Müller", "px": 42, "py": 48,
"desc": "Central area near the museum"},
"CAM-04": {"name": "Hoenderloo Path", "px": 72, "py": 32,
"desc": "Eastern forest corridor"},
"CAM-05": {"name": "Deelense Veld", "px": 55, "py": 68,
"desc": "Southern heathland near Schaarsbergen"},
}
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.webp")
def serve_map():
return send_from_directory(".", "map.webp")
@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>De Hoge Veluwe — 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>Nationaal Park De Hoge Veluwe</h1><p>Wildlife Camera Monitoring</p></div>
<!-- map -->
<div id="map-outer">
<div id="map-wrap">
<img id="map-img" src="/map.webp" 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 }} &middot; {{ c.name }}</div>
{% endfor %}
</div>
</div>
<div id="toast"></div>
<button id="sidebar-toggle" onclick="toggleSidebar()" class="shifted">&#9664; 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 showWolfWarning(cid){
const c=CAMS[cid]; if(!c)return;
const wrap=document.getElementById('map-wrap');
const old=document.getElementById('wolf-'+cid);
if(old)old.remove();
const oldLbl=document.getElementById('wolf-lbl-'+cid);
if(oldLbl)oldLbl.remove();
const circle=document.createElement('div');
circle.className='wolf-warn';circle.id='wolf-'+cid;
circle.style.left=c.px+'%';circle.style.top=c.py+'%';
const lbl=document.createElement('div');
lbl.className='wolf-warn-label';lbl.id='wolf-lbl-'+cid;
lbl.style.left=c.px+'%';lbl.style.top=c.py+'%';
lbl.textContent='\u26A0 Wolf 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') showWolfWarning(d.cam);
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 }} — De Hoge Veluwe</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="/">&#8592; Dashboard</a>
<div class="topbar-info">
<div class="topbar-title">Detection {{ det.id }}</div>
<div class="topbar-sub">
{{ det.cam }} &middot; {{ cameras[det.cam].name }} &middot; {{ 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> &mdash; 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 &hellip;</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()">&lsaquo;</button>
<div class="slide-title">
<span id="slide-title">Loading&hellip;</span>
<span class="slide-counter" id="slide-counter"></span>
</div>
<button class="nav-btn" onclick="next()">&rsaquo;</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> &middot; 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="/">&#8592; Dashboard</a>
<div class="topbar-info">
<div class="topbar-title">{{ cam_id }} &mdash; {{ 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)