Final Update

This commit is contained in:
2026-04-12 09:28:14 -04:00
parent 96272aedb7
commit 3a995f7c32
6 changed files with 246 additions and 15 deletions
+7 -2
View File
@@ -76,8 +76,11 @@ def analyze_room_image(image_bytes: bytes):
row_ind, col_ind = linear_sum_assignment(dist_matrix) row_ind, col_ind = linear_sum_assignment(dist_matrix)
DISTANCE_THRESHOLD = 800.0 # Increased threshold to ensure grouping even if they are far in the camera view
for person_idx, chair_idx in zip(row_ind, col_ind): for person_idx, chair_idx in zip(row_ind, col_ind):
distance = float(dist_matrix[person_idx, chair_idx]) distance = float(dist_matrix[person_idx, chair_idx])
if distance <= DISTANCE_THRESHOLD:
pairs.append({ pairs.append({
"person_index": int(person_idx), "person_index": int(person_idx),
"chair_index": int(chair_idx), "chair_index": int(chair_idx),
@@ -86,13 +89,15 @@ def analyze_room_image(image_bytes: bytes):
is_full = num_people >= num_chairs if num_chairs > 0 else False available_chairs = num_chairs - len(pairs)
is_full = available_chairs <= 0 if num_chairs > 0 else False
return { return {
"room_status": "full" if is_full else "available", "room_status": "full" if is_full else "available",
"counts": { "counts": {
"people": num_people, "people": num_people,
"chairs": num_chairs "chairs": num_chairs,
"available_chairs": available_chairs
}, },
"pairs": pairs, "pairs": pairs,
"details": { "details": {
+6
View File
@@ -84,6 +84,11 @@ def generate_fake_data():
for loc in UMD_LOCATIONS: for loc in UMD_LOCATIONS:
db_level = get_db_for_time_and_location(hour, loc["id"]) db_level = get_db_for_time_and_location(hour, loc["id"])
# Estimate people based on noise level.
# 35dB = ~0 people. Every 1.5 dB above 35 adds ~1 person.
base_people = max(0, (db_level - 35) * 1.5)
people_count = int(max(0, base_people + random.uniform(-5, 10)))
doc = { doc = {
"room_id": loc["id"], "room_id": loc["id"],
"location": { "location": {
@@ -91,6 +96,7 @@ def generate_fake_data():
"coordinates": [loc["lng"], loc["lat"]] "coordinates": [loc["lng"], loc["lat"]]
}, },
"db": round(db_level, 2), "db": round(db_level, 2),
"people": people_count,
"date": current_time "date": current_time
} }
docs_to_insert.append(doc) docs_to_insert.append(doc)
@@ -92,6 +92,7 @@
properties: { properties: {
room_id: room.room_id, room_id: room.room_id,
db: room.db, db: room.db,
people: room.people || 0,
name: locName, name: locName,
date: room.date, date: room.date,
}, },
@@ -114,6 +115,7 @@
properties: { properties: {
room_id: loc.id, room_id: loc.id,
db: 0, db: 0,
people: 0,
name: loc.name, name: loc.name,
date: new Date().toISOString(), date: new Date().toISOString(),
}, },
@@ -264,6 +266,19 @@
const rawName = props.name.split("\\n")[0].split("\n")[0]; const rawName = props.name.split("\\n")[0].split("\n")[0];
let occStatus = "Quiet";
let occColor = "#94e2d5";
if (props.people > 60) {
occStatus = "Packed";
occColor = "#f38ba8";
} else if (props.people > 25) {
occStatus = "Moderate";
occColor = "#f9e2af";
} else if (props.people === 0 && props.db === 0) {
occStatus = "No Data";
occColor = "#888888";
}
return ` return `
<div class="px-3 py-2 bg-crust/90 backdrop-blur-md border border-white/10 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.5)] min-w-[150px] text-text"> <div class="px-3 py-2 bg-crust/90 backdrop-blur-md border border-white/10 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.5)] min-w-[150px] text-text">
<h3 class="font-display font-bold text-sm mb-1 text-white border-b border-white/10 pb-1">${rawName}</h3> <h3 class="font-display font-bold text-sm mb-1 text-white border-b border-white/10 pb-1">${rawName}</h3>
@@ -272,6 +287,10 @@
<span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span> <span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span>
</div> </div>
<p class="text-xs text-subtext0 mt-1">Noise: <span class="font-mono text-white">${props.db > 0 ? props.db.toFixed(1) + " dB" : "N/A"}</span></p> <p class="text-xs text-subtext0 mt-1">Noise: <span class="font-mono text-white">${props.db > 0 ? props.db.toFixed(1) + " dB" : "N/A"}</span></p>
<p class="text-xs text-subtext0 mt-1 flex items-center gap-1">
Occupancy: <span class="font-mono" style="color: ${occColor}">${props.people}</span>
<span class="text-[10px]" style="color: ${occColor}">(${occStatus})</span>
</p>
<p class="text-[10px] text-surface2 mt-2 font-mono">Last updated: ${timeStr}</p> <p class="text-[10px] text-surface2 mt-2 font-mono">Last updated: ${timeStr}</p>
</div> </div>
`; `;
@@ -6,7 +6,7 @@
let Chart: any; let Chart: any;
let chartCanvas: HTMLCanvasElement; let chartCanvas: HTMLCanvasElement | undefined = $state();
let chartInstance: any; let chartInstance: any;
function closePopup() { function closePopup() {
@@ -46,9 +46,25 @@
return Math.round(sum / points.length); return Math.round(sum / points.length);
}); });
let currentPeople = $derived.by(() => {
if (!mapState.selectedLocation || !mapState.historyData) return 0;
const points = mapState.historyData
.filter(d => d.room_id === mapState.selectedLocation?.id)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (points.length === 0) return 0;
return points[0].people || 0;
});
let statusColor = $derived(getChartColor(current2hAvg, themeState.isLight, themeState.isColorBlindFriendly)); let statusColor = $derived(getChartColor(current2hAvg, themeState.isLight, themeState.isColorBlindFriendly));
let statusLabel = $derived(getStatusLabel(current2hAvg)); let statusLabel = $derived(getStatusLabel(current2hAvg));
let occStatus = $derived.by(() => {
if (currentPeople > 60) return { label: "Packed", color: "#f38ba8" };
if (currentPeople > 25) return { label: "Moderate", color: "#f9e2af" };
if (currentPeople === 0 && current2hAvg === 0) return { label: "No Data", color: "#888888" };
return { label: "Quiet", color: "#94e2d5" };
});
function drawChart() { function drawChart() {
if (!Chart || !chartCanvas || !mapState.selectedLocation) return; if (!Chart || !chartCanvas || !mapState.selectedLocation) return;
if (chartInstance) chartInstance.destroy(); if (chartInstance) chartInstance.destroy();
@@ -65,8 +81,8 @@
const isCB = themeState.isColorBlindFriendly; const isCB = themeState.isColorBlindFriendly;
const ctx = chartCanvas.getContext('2d'); const ctx = chartCanvas.getContext('2d');
let gradientLine = statusColor; let gradientLine: any = statusColor;
let gradientFill = statusColor + '33'; let gradientFill: any = statusColor + '33';
if (ctx) { if (ctx) {
gradientLine = ctx.createLinearGradient(0, 0, 0, 200); gradientLine = ctx.createLinearGradient(0, 0, 0, 200);
@@ -95,7 +111,7 @@
borderWidth: 3, borderWidth: 3,
fill: true, fill: true,
segment: { segment: {
borderColor: ctx => getChartColor(ctx.p1.parsed.y, isLight, isCB) borderColor: (ctx: any) => getChartColor(ctx.p1.parsed.y, isLight, isCB)
}, },
tension: 0.5, tension: 0.5,
pointRadius: 0, pointRadius: 0,
@@ -145,6 +161,7 @@
onMount(async () => { onMount(async () => {
const chartModule = await import('chart.js/auto'); const chartModule = await import('chart.js/auto');
// @ts-ignore
const chartjsAdapter = await import('chartjs-adapter-date-fns'); const chartjsAdapter = await import('chartjs-adapter-date-fns');
Chart = chartModule.default; Chart = chartModule.default;
if (mapState.selectedLocation) drawChart(); if (mapState.selectedLocation) drawChart();
@@ -163,11 +180,20 @@
<Icon icon="mdi:close" class="text-xl" /> <Icon icon="mdi:close" class="text-xl" />
</button> </button>
<h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2> <h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2>
<div class="flex flex-col gap-1 mt-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full shadow-[0_0_8px_currentColor]" style="color: {statusColor}; background-color: {statusColor};"></div> <div class="w-3 h-3 rounded-full shadow-[0_0_8px_currentColor]" style="color: {statusColor}; background-color: {statusColor};"></div>
<span class="text-sm font-medium" style="color: {statusColor}">{statusLabel}</span> <span class="text-sm font-medium" style="color: {statusColor}">{statusLabel}</span>
<span class="text-slate-400 text-sm ml-1">· {current2hAvg} dB Avg (Last 2h)</span> <span class="text-slate-400 text-sm ml-1">· {current2hAvg} dB Avg (Last 2h)</span>
</div> </div>
<div class="flex items-center gap-2">
<Icon icon="mdi:account-group" class="text-lg" style="color: {occStatus.color}" />
<span class="text-sm font-medium" style="color: {occStatus.color}">{occStatus.label}</span>
<span class="text-slate-400 text-sm ml-1">· {currentPeople} people currently</span>
</div>
</div>
</div> </div>
<!-- Usually quiet logic --> <!-- Usually quiet logic -->
+6
View File
@@ -34,6 +34,12 @@
<span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">History</span> <span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">History</span>
</a> </a>
<!-- Nav Item: Vision -->
<a href="/vision" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/vision' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)] bg-white/5' : 'text-slate-400 hover:text-white'}">
<Icon icon="mdi:camera" class="text-2xl" />
<span class="absolute left-full ml-4 px-2 py-1 bg-black/80 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none md:block hidden border border-white/10">Vision</span>
</a>
<!-- Nav Item: Settings --> <!-- Nav Item: Settings -->
<a href="/settings" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/settings' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)]' : 'text-slate-400 hover:text-white'}"> <a href="/settings" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/settings' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)]' : 'text-slate-400 hover:text-white'}">
<Icon icon="mdi:cog-outline" class="text-2xl" /> <Icon icon="mdi:cog-outline" class="text-2xl" />
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Icon from '@iconify/svelte';
let videoElement: HTMLVideoElement;
let canvasElement: HTMLCanvasElement;
let hiddenCanvas: HTMLCanvasElement;
let stream: MediaStream | null = null;
let intervalId: any;
let roomStatus = $state('unknown');
let peopleCount = $state(0);
let chairsCount = $state(0);
let availableChairsCount = $state(0);
onMount(async () => {
hiddenCanvas = document.createElement('canvas');
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
if (videoElement) {
videoElement.srcObject = stream;
videoElement.play();
}
} catch (err) {
console.error("Camera access denied or unavailable", err);
}
intervalId = setInterval(processFrame, 1500);
});
onDestroy(() => {
if (intervalId) clearInterval(intervalId);
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
});
async function processFrame() {
if (!videoElement || videoElement.readyState !== videoElement.HAVE_ENOUGH_DATA) return;
const width = videoElement.videoWidth;
const height = videoElement.videoHeight;
hiddenCanvas.width = width;
hiddenCanvas.height = height;
const ctx = hiddenCanvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(videoElement, 0, 0, width, height);
hiddenCanvas.toBlob(async (blob) => {
if (!blob) return;
const formData = new FormData();
formData.append('file', blob, 'frame.jpg');
try {
const response = await fetch('http://localhost:8000/api/vision/room-status', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
roomStatus = data.room_status;
peopleCount = data.counts?.people || 0;
chairsCount = data.counts?.chairs || 0;
availableChairsCount = data.counts?.available_chairs || 0;
drawOverlay(data, width, height);
}
} catch (e) {
console.error("Failed to process vision frame:", e);
}
}, 'image/jpeg');
}
function drawOverlay(data: any, videoWidth: number, videoHeight: number) {
if (!canvasElement) return;
const rect = canvasElement.getBoundingClientRect();
canvasElement.width = rect.width;
canvasElement.height = rect.height;
const ctx = canvasElement.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
const scaleX = canvasElement.width / videoWidth;
const scaleY = canvasElement.height / videoHeight;
ctx.scale(scaleX, scaleY);
const people = data.details?.people || [];
const chairs = data.details?.chairs || [];
const pairs = data.pairs || [];
ctx.lineWidth = 3;
people.forEach((p: any) => {
ctx.strokeStyle = '#1e66f5'; // blue for people
ctx.strokeRect(p.box[0], p.box[1], p.box[2] - p.box[0], p.box[3] - p.box[1]);
});
chairs.forEach((c: any) => {
ctx.strokeStyle = '#40a02b'; // green for chairs
ctx.strokeRect(c.box[0], c.box[1], c.box[2] - c.box[0], c.box[3] - c.box[1]);
});
// Draw connecting lines between pairs
ctx.strokeStyle = '#df8e1d'; // orange/yellow
ctx.setLineDash([5, 5]);
ctx.beginPath();
pairs.forEach((pair: any) => {
const person = people[pair.person_index];
const chair = chairs[pair.chair_index];
if (person && chair) {
ctx.moveTo(person.centroid[0], person.centroid[1]);
ctx.lineTo(chair.centroid[0], chair.centroid[1]);
}
});
ctx.stroke();
ctx.setLineDash([]); // Reset line dash
}
</script>
<svelte:window on:resize={() => {
// Re-process layout or clear overlay on resize if needed
if (canvasElement) {
const ctx = canvasElement.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
}
}} />
<div class="h-full w-full flex flex-col md:flex-row items-center justify-center p-6 gap-6 pt-20 md:pt-6 md:pl-24 overflow-y-auto">
<div class="relative w-full max-w-4xl rounded-3xl overflow-hidden glass-panel border border-white/10 shadow-2xl flex-shrink-0">
<video bind:this={videoElement} class="w-full h-auto object-cover block" playsinline muted></video>
<canvas bind:this={canvasElement} class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<div class="w-full max-w-md glass-panel rounded-3xl border border-white/10 p-6 flex flex-col gap-4 shadow-xl shrink-0">
<h2 class="font-display font-semibold text-2xl text-white mb-2 flex items-center gap-2">
<Icon icon="mdi:cctv" class="text-neon-blue" /> Live Vision
</h2>
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
<span class="text-slate-400 font-medium text-sm">Status</span>
<span class="font-bold text-lg {roomStatus === 'full' ? 'text-red-400' : roomStatus === 'available' ? 'text-green-400' : 'text-slate-400'} capitalize">
{roomStatus}
</span>
</div>
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
<span class="text-slate-400 font-medium text-sm flex items-center gap-2"><Icon icon="mdi:account" /> People</span>
<span class="font-bold text-xl text-white">{peopleCount}</span>
</div>
<div class="flex justify-between items-center p-4 bg-white/5 rounded-xl border border-white/10">
<span class="text-slate-400 font-medium text-sm flex items-center gap-2"><Icon icon="mdi:chair-school" /> Available Chairs</span>
<span class="font-bold text-xl text-white">{availableChairsCount} / {chairsCount}</span>
</div>
</div>
</div>
<style>
:global(.glass-panel) {
background: var(--color-panel-glass);
backdrop-filter: blur(12px);
}
</style>