diff --git a/web/dashboard.py b/web/dashboard.py
index 357e116..218b39e 100644
--- a/web/dashboard.py
+++ b/web/dashboard.py
@@ -1490,6 +1490,38 @@ def mark_training():
return {"ok": False, "error": str(e)}, 500
+@app.route("/api/feeder-image/capture", methods=["POST"])
+def capture_feeder_image():
+ """Capture current camera frame and save as feeder reference photo."""
+ import cv2
+
+ if _monitor is None or not _monitor.running:
+ return {"ok": False, "error": "Camera not running"}, 503
+
+ try:
+ frame = _monitor.camera.get_full_res_frame()
+ if frame is None:
+ frame = _monitor.camera.capture_lores_array()
+
+ if frame.shape[0] > frame.shape[1] * 1.2:
+ frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
+
+ destinations = [
+ config.BASE_DIR / "web" / "static" / "feeder.jpg",
+ config.BASE_DIR / "website" / "img" / "feeder.jpg",
+ ]
+ for dest in destinations:
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ cv2.imwrite(str(dest), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
+
+ logger.info("Feeder reference image updated")
+ return {"ok": True}
+
+ except Exception as e:
+ logger.exception("Failed to capture feeder reference image")
+ return {"ok": False, "error": str(e)}, 500
+
+
_retrain_in_progress = False
_retrain_lock = threading.Lock()
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html
index eea8e35..34f5c62 100644
--- a/web/templates/dashboard.html
+++ b/web/templates/dashboard.html
@@ -248,6 +248,7 @@
Live Camera Feed
+
+
+
Bird Analytics
+
Loading analytics...
+
+
+
+
+
+
+
Visits This Week
+
--
+
+
+
+
Activity Streak
+
--
+
consecutive days
+
+
+
+
+
+
+
+
+
Feeder Position Heatmap
+
+
Where birds appear in frame (30 days)
+
+
+
+
+
Hourly Activity
+
+
+ 12a6a12p6p11p
+
+
+
+
+
+
+
Behavior Breakdown (30 days)
+
+
+
+
+
Hardware
@@ -846,6 +901,19 @@
Recent Logs
.catch(() => {});
}
+ function setFeederReference() {
+ const btn = document.getElementById('setFeederRefBtn');
+ btn.textContent = 'Saving...'; btn.disabled = true;
+ fetch('/api/feeder-image/capture', { method: 'POST' })
+ .then(r => r.json())
+ .then(data => {
+ if (data.ok) showToast('Feeder reference saved — heatmap will use this photo');
+ else showToast('Error: ' + (data.error || 'unknown'));
+ btn.textContent = 'Set Feeder Reference'; btn.disabled = false;
+ })
+ .catch(() => { showToast('Capture failed'); btn.textContent = 'Set Feeder Reference'; btn.disabled = false; });
+ }
+
function markHummingbird() {
fetch('/api/training/mark', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({label: 'hummingbird'}) })
.then(r => r.json())
@@ -862,6 +930,115 @@ Recent Logs
refreshTrainingCount();
+ // Analytics section
+ function fmtHour(h) {
+ if (h === 0) return '12a';
+ if (h < 12) return h + 'a';
+ if (h === 12) return '12p';
+ return (h - 12) + 'p';
+ }
+
+ function renderDashHeatmap(heatmap) {
+ const canvas = document.getElementById('dashHeatmapCanvas');
+ if (!canvas || !heatmap || !heatmap.length) return;
+ const ctx = canvas.getContext('2d');
+ const gridSize = heatmap.length;
+ const cellW = canvas.width / gridSize;
+ const cellH = canvas.height / gridSize;
+ let maxVal = 0;
+ for (const row of heatmap) for (const v of row) if (v > maxVal) maxVal = v;
+ if (maxVal === 0) return;
+
+ function drawOverlay() {
+ for (let r = 0; r < gridSize; r++) {
+ for (let c = 0; c < gridSize; c++) {
+ const intensity = heatmap[r][c] / maxVal;
+ if (intensity === 0) continue;
+ const alpha = Math.min(intensity * 0.7 + 0.15, 0.85);
+ ctx.fillStyle = `rgba(92, 184, 76, ${alpha})`;
+ ctx.fillRect(c * cellW, r * cellH, cellW - 1, cellH - 1);
+ ctx.fillStyle = intensity > 0.5 ? '#fff' : '#e8f0ea';
+ ctx.font = 'bold 10px monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(heatmap[r][c], c * cellW + cellW / 2, r * cellH + cellH / 2 + 3);
+ }
+ }
+ }
+
+ const img = new Image();
+ img.onload = () => { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); drawOverlay(); };
+ img.onerror = () => { ctx.fillStyle = '#0d1a12'; ctx.fillRect(0, 0, canvas.width, canvas.height); drawOverlay(); };
+ img.src = '/static/feeder.jpg';
+ }
+
+ function renderHourlyBars(hourly) {
+ const container = document.getElementById('a-hourly-bars');
+ if (!container || !hourly || !hourly.length) return;
+ const maxH = Math.max(...hourly, 1);
+ container.innerHTML = hourly.map((v, i) => {
+ const pct = Math.round((v / maxH) * 100);
+ const label = fmtHour(i);
+ return ``;
+ }).join('');
+ }
+
+ function renderBehaviorBars(breakdown) {
+ const section = document.getElementById('a-behavior-section');
+ const container = document.getElementById('a-behavior-bars');
+ if (!section || !container || !breakdown) return;
+ const entries = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
+ if (!entries.length) return;
+ const total = entries.reduce((s, [, v]) => s + v, 0) || 1;
+ const colors = { feeding: '#5cb84c', hovering: '#58a6ff', perching: '#f0c040', chasing: '#e05535' };
+ container.innerHTML = entries.map(([name, count]) => {
+ const pct = Math.round((count / total) * 100);
+ const color = colors[name] || '#a3b8a6';
+ return `
+
+ ${escapeHtml(name)}${count} (${pct}%)
+
+
+
`;
+ }).join('');
+ section.style.display = '';
+ }
+
+ function loadAnalytics() {
+ fetch('/api/analytics')
+ .then(r => r.json())
+ .then(data => {
+ document.getElementById('analyticsLoading').style.display = 'none';
+ document.getElementById('analyticsContent').style.display = '';
+
+ const vs = data.visit_stats || {};
+ document.getElementById('a-visits-today').textContent = vs.visits_today != null ? vs.visits_today : '--';
+ document.getElementById('a-visits-week').textContent = vs.visits_week != null ? vs.visits_week : '--';
+
+ if (vs.peak_hour != null) {
+ document.getElementById('a-peak-hour').textContent = fmtHour(vs.peak_hour);
+ }
+
+ const streaks = data.activity_streaks || {};
+ if (streaks.current_streak != null) {
+ document.getElementById('a-streak').textContent = streaks.current_streak;
+ document.getElementById('a-streak').className = 'value ' + (streaks.current_streak > 7 ? 'green' : streaks.current_streak > 0 ? 'yellow' : '');
+ }
+
+ if (data.position_heatmap) renderDashHeatmap(data.position_heatmap);
+ if (data.hourly_distribution) renderHourlyBars(data.hourly_distribution);
+ if (data.behavior_breakdown) renderBehaviorBars(data.behavior_breakdown);
+ })
+ .catch(() => {
+ document.getElementById('analyticsLoading').textContent = 'Analytics unavailable';
+ });
+ }
+
+ loadAnalytics();
+ setInterval(loadAnalytics, 120000);
function checkForUpdate() {
const btn = document.getElementById('updateBtn');
diff --git a/website/js/stats.js b/website/js/stats.js
index 4263ee8..c94de8f 100644
--- a/website/js/stats.js
+++ b/website/js/stats.js
@@ -402,9 +402,11 @@ function renderSpeciesChart(breakdown) {
}
/**
- * Render position heatmap on canvas (8x8 grid)
+ * Render position heatmap on canvas (8x8 grid), optionally with a feeder photo background.
+ * If bgImageUrl is provided, the photo is drawn first and heat cells are semi-transparent
+ * so the feeder shows through. Empty cells (count=0) are left fully transparent.
*/
-function renderHeatmapGrid(heatmap) {
+function renderHeatmapGrid(heatmap, bgImageUrl) {
const canvas = document.getElementById('heatmapCanvas');
if (!canvas || !heatmap || !heatmap.length) return;
@@ -413,31 +415,43 @@ function renderHeatmapGrid(heatmap) {
const cellW = canvas.width / gridSize;
const cellH = canvas.height / gridSize;
- // Find max for normalization
let maxVal = 0;
- for (const row of heatmap) {
- for (const v of row) {
- if (v > maxVal) maxVal = v;
- }
- }
+ for (const row of heatmap) for (const v of row) if (v > maxVal) maxVal = v;
if (maxVal === 0) return;
- for (let r = 0; r < gridSize; r++) {
- for (let c = 0; c < gridSize; c++) {
- const intensity = heatmap[r][c] / maxVal;
- const alpha = Math.min(intensity * 0.9 + 0.05, 1);
- ctx.fillStyle = `rgba(92, 184, 76, ${alpha})`;
- ctx.fillRect(c * cellW, r * cellH, cellW - 1, cellH - 1);
-
- // Show count if > 0
- if (heatmap[r][c] > 0) {
- ctx.fillStyle = intensity > 0.5 ? '#fff' : '#9098a8';
- ctx.font = '11px monospace';
+ function drawOverlay() {
+ for (let r = 0; r < gridSize; r++) {
+ for (let c = 0; c < gridSize; c++) {
+ const intensity = heatmap[r][c] / maxVal;
+ if (intensity === 0) continue;
+ const alpha = Math.min(intensity * 0.7 + 0.15, 0.85);
+ ctx.fillStyle = `rgba(92, 184, 76, ${alpha})`;
+ ctx.fillRect(c * cellW, r * cellH, cellW - 1, cellH - 1);
+ ctx.fillStyle = intensity > 0.5 ? '#fff' : '#e8f0ea';
+ ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText(heatmap[r][c], c * cellW + cellW / 2, r * cellH + cellH / 2 + 4);
}
}
}
+
+ if (bgImageUrl) {
+ const img = new Image();
+ img.onload = () => {
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ drawOverlay();
+ };
+ img.onerror = () => {
+ ctx.fillStyle = '#0d1a12';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawOverlay();
+ };
+ img.src = bgImageUrl;
+ } else {
+ ctx.fillStyle = '#0d1a12';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawOverlay();
+ }
}
/**
@@ -659,7 +673,7 @@ document.addEventListener('DOMContentLoaded', async () => {
populateVisitPatterns(data);
renderBehaviorChart(data.behavior_breakdown);
renderSpeciesChart(data.species_breakdown);
- renderHeatmapGrid(data.position_heatmap);
+ renderHeatmapGrid(data.position_heatmap, 'img/feeder.jpg');
populateStreaks(data.activity_streaks);
populateYoY(data.yoy_comparison);
renderMonthlyChart(data.monthly_totals);