Final Update
This commit is contained in:
+12
-7
@@ -76,23 +76,28 @@ def analyze_room_image(image_bytes: bytes):
|
||||
|
||||
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):
|
||||
distance = float(dist_matrix[person_idx, chair_idx])
|
||||
pairs.append({
|
||||
"person_index": int(person_idx),
|
||||
"chair_index": int(chair_idx),
|
||||
"distance": distance
|
||||
})
|
||||
if distance <= DISTANCE_THRESHOLD:
|
||||
pairs.append({
|
||||
"person_index": int(person_idx),
|
||||
"chair_index": int(chair_idx),
|
||||
"distance": distance
|
||||
})
|
||||
|
||||
|
||||
|
||||
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 {
|
||||
"room_status": "full" if is_full else "available",
|
||||
"counts": {
|
||||
"people": num_people,
|
||||
"chairs": num_chairs
|
||||
"chairs": num_chairs,
|
||||
"available_chairs": available_chairs
|
||||
},
|
||||
"pairs": pairs,
|
||||
"details": {
|
||||
|
||||
@@ -83,6 +83,11 @@ def generate_fake_data():
|
||||
|
||||
for loc in UMD_LOCATIONS:
|
||||
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 = {
|
||||
"room_id": loc["id"],
|
||||
@@ -91,6 +96,7 @@ def generate_fake_data():
|
||||
"coordinates": [loc["lng"], loc["lat"]]
|
||||
},
|
||||
"db": round(db_level, 2),
|
||||
"people": people_count,
|
||||
"date": current_time
|
||||
}
|
||||
docs_to_insert.append(doc)
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
properties: {
|
||||
room_id: room.room_id,
|
||||
db: room.db,
|
||||
people: room.people || 0,
|
||||
name: locName,
|
||||
date: room.date,
|
||||
},
|
||||
@@ -114,6 +115,7 @@
|
||||
properties: {
|
||||
room_id: loc.id,
|
||||
db: 0,
|
||||
people: 0,
|
||||
name: loc.name,
|
||||
date: new Date().toISOString(),
|
||||
},
|
||||
@@ -263,6 +265,19 @@
|
||||
}
|
||||
|
||||
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 `
|
||||
<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">
|
||||
@@ -272,6 +287,10 @@
|
||||
<span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span>
|
||||
</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 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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
let Chart: any;
|
||||
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let chartInstance: any;
|
||||
|
||||
function closePopup() {
|
||||
@@ -46,8 +46,24 @@
|
||||
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 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() {
|
||||
if (!Chart || !chartCanvas || !mapState.selectedLocation) return;
|
||||
@@ -65,8 +81,8 @@
|
||||
const isCB = themeState.isColorBlindFriendly;
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
let gradientLine = statusColor;
|
||||
let gradientFill = statusColor + '33';
|
||||
let gradientLine: any = statusColor;
|
||||
let gradientFill: any = statusColor + '33';
|
||||
|
||||
if (ctx) {
|
||||
gradientLine = ctx.createLinearGradient(0, 0, 0, 200);
|
||||
@@ -95,7 +111,7 @@
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
segment: {
|
||||
borderColor: ctx => getChartColor(ctx.p1.parsed.y, isLight, isCB)
|
||||
borderColor: (ctx: any) => getChartColor(ctx.p1.parsed.y, isLight, isCB)
|
||||
},
|
||||
tension: 0.5,
|
||||
pointRadius: 0,
|
||||
@@ -145,6 +161,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
const chartModule = await import('chart.js/auto');
|
||||
// @ts-ignore
|
||||
const chartjsAdapter = await import('chartjs-adapter-date-fns');
|
||||
Chart = chartModule.default;
|
||||
if (mapState.selectedLocation) drawChart();
|
||||
@@ -163,10 +180,19 @@
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
<h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col gap-1 mt-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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</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 -->
|
||||
<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" />
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user