From 539267f4a40dbea63175839a7dc314d1cbb42175 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 23:17:32 +0000 Subject: [PATCH 1/2] Add feeder photo background to heatmap, analytics section to local dashboard - Update renderHeatmapGrid() to accept an optional feeder photo URL; the image is drawn as canvas background and heatmap cells are semi-transparent so the feeder photo shows through beneath the heat colors. Empty cells (count=0) are fully transparent so the feeder is visible in quiet zones. Falls back to dark background if photo is missing. - Public stats page now loads img/feeder.jpg as the heatmap background; place a photo of your feeder at website/img/feeder.jpg to enable it. - Add Bird Analytics section to the local Flask dashboard (port 8080) with summary cards (visits today/week, peak hour, streak), the position heatmap using /static/feeder.jpg as background, a 24-hour activity bar chart, and a behavior breakdown with percentage bars. Data is fetched from /api/analytics on load and every 2 minutes. https://claude.ai/code/session_01PThGWo2FsMsk3v3Yb61ffx --- web/templates/dashboard.html | 163 +++++++++++++++++++++++++++++++++++ website/js/stats.js | 54 +++++++----- 2 files changed, 197 insertions(+), 20 deletions(-) diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index eea8e35..3cb1030 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -345,6 +345,60 @@

App Status

+
+

Bird Analytics

+
Loading analytics...
+ +
+

Hardware

@@ -862,6 +916,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); From 2f169481697be14471f0f0ae1cd4d63cfc157701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:14:48 +0000 Subject: [PATCH 2/2] Add Set Feeder Reference button to dashboard Adds a POST /api/feeder-image/capture endpoint that grabs the current full-res camera frame and writes it to both web/static/feeder.jpg (local dashboard heatmap) and website/img/feeder.jpg (public stats page heatmap). A "Set Feeder Reference" button appears in the live feed controls alongside the existing mark-yes/no buttons. https://claude.ai/code/session_01PThGWo2FsMsk3v3Yb61ffx --- web/dashboard.py | 32 ++++++++++++++++++++++++++++++++ web/templates/dashboard.html | 14 ++++++++++++++ 2 files changed, 46 insertions(+) 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 3cb1030..34f5c62 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -248,6 +248,7 @@

Live Camera Feed

+