feat: Add backfill script for image brightness stats and display them in the UI.
This commit is contained in:
112
backfill_stats.js
Normal file
112
backfill_stats.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.PICUPPER_UPLOAD_DIR || path.join(__dirname, 'uploads');
|
||||||
|
|
||||||
|
async function processDirectory(dir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
// Check if we are in a leaf "day" directory (has images)
|
||||||
|
const images = entries.filter(e => e.isFile() && /\.(jpg|jpeg|png|webp)$/i.test(e.name) && !e.name.includes('_thumb'));
|
||||||
|
|
||||||
|
if (images.length > 0) {
|
||||||
|
console.log(`Processing directory: ${dir}`);
|
||||||
|
const statsFile = path.join(dir, 'stats.json');
|
||||||
|
let stats = [];
|
||||||
|
|
||||||
|
if (fs.existsSync(statsFile)) {
|
||||||
|
try {
|
||||||
|
stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error reading ${statsFile}`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a set of existing filenames for fast lookup
|
||||||
|
const processedFiles = new Set(stats.map(s => s.filename));
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
for (const img of images) {
|
||||||
|
if (processedFiles.has(img.name)) continue;
|
||||||
|
|
||||||
|
const imgPath = path.join(dir, img.name);
|
||||||
|
console.log(` Computing brightness for ${img.name}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = sharp(imgPath);
|
||||||
|
const s = await image.stats();
|
||||||
|
const brightness = Math.round((s.channels[0].mean + s.channels[1].mean + s.channels[2].mean) / 3);
|
||||||
|
|
||||||
|
// Try to extract timestamp from filename: {cameraId}_{timestamp}.{ext}
|
||||||
|
// Timestamp in filename is usually ISO like 2024-12-19T14-00-00-000Z but with - instead of :
|
||||||
|
// We need a valid ISO string for consistency.
|
||||||
|
|
||||||
|
// Filename format: {cameraId}_YYYY-MM-DDTHH-mm-ss-sssZ.{ext}
|
||||||
|
// extract YYYY-MM-DDTHH-mm-ss-sssZ
|
||||||
|
let timestamp = new Date().toISOString();
|
||||||
|
const match = img.name.match(/_(\d{4}-\d{2}-\d{2}T[\d-]+Z)\./);
|
||||||
|
if (match) {
|
||||||
|
// Replace - with : in time part for standard ISO parseability if needed,
|
||||||
|
// or just store as is if that's what we did in server.js.
|
||||||
|
// In server.js: new Date().toISOString().replace(/[:.]/g, '-') was used for filename.
|
||||||
|
// For the stats entry, server.js uses new Date().toISOString() (standard ISO with colons).
|
||||||
|
|
||||||
|
// Reconstruct standard ISO
|
||||||
|
// stored format: 2025-12-19T14-25-00-123Z
|
||||||
|
// target: 2025-12-19T14:25:00.123Z
|
||||||
|
const rawTs = match[1];
|
||||||
|
const parts = rawTs.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/);
|
||||||
|
if (parts) {
|
||||||
|
timestamp = `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}.${parts[7]}Z`;
|
||||||
|
} else {
|
||||||
|
// Fallback for older formats if any
|
||||||
|
timestamp = rawTs;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to file mtime
|
||||||
|
const fstats = fs.statSync(imgPath);
|
||||||
|
timestamp = fstats.mtime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.push({
|
||||||
|
timestamp: timestamp,
|
||||||
|
filename: img.name,
|
||||||
|
brightness: brightness
|
||||||
|
});
|
||||||
|
updated = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Failed to process ${img.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
// Sort by timestamp
|
||||||
|
stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
|
||||||
|
console.log(` Updated ${statsFile} with new entries.`);
|
||||||
|
} else {
|
||||||
|
console.log(` No new images to process.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subdirectories
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await processDirectory(path.join(dir, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
console.log(`Starting brightness backfill in ${UPLOAD_DIR}...`);
|
||||||
|
processDirectory(UPLOAD_DIR).then(() => {
|
||||||
|
console.log('Done!');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
});
|
||||||
@@ -173,6 +173,13 @@
|
|||||||
<div id="loader" class="loader">Loading images...</div>
|
<div id="loader" class="loader">Loading images...</div>
|
||||||
<div id="grid" class="grid"></div>
|
<div id="grid" class="grid"></div>
|
||||||
<div id="empty" class="empty">Select a camera and date to view images</div>
|
<div id="empty" class="empty">Select a camera and date to view images</div>
|
||||||
|
|
||||||
|
<!-- Brightness Chart -->
|
||||||
|
<div id="chartContainer"
|
||||||
|
style="margin-top: 40px; background: var(--card-bg); padding: 20px; border-radius: 8px; display: none;">
|
||||||
|
<h3>Brightness Levels</h3>
|
||||||
|
<canvas id="brightnessChart" style="width: 100%; height: 200px;"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightbox Modal -->
|
<!-- Lightbox Modal -->
|
||||||
@@ -292,7 +299,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImages() {
|
async function loadImages(isAutoUpdate = false) {
|
||||||
const cameraId = cameraSelect.value;
|
const cameraId = cameraSelect.value;
|
||||||
const dateStr = dateSelect.value; // YYYY-MM-DD
|
const dateStr = dateSelect.value; // YYYY-MM-DD
|
||||||
|
|
||||||
@@ -304,9 +311,12 @@
|
|||||||
localStorage.setItem('lastCamera', cameraId);
|
localStorage.setItem('lastCamera', cameraId);
|
||||||
localStorage.setItem('lastDate', dateStr);
|
localStorage.setItem('lastDate', dateStr);
|
||||||
|
|
||||||
|
if (!isAutoUpdate) {
|
||||||
loader.style.display = 'block';
|
loader.style.display = 'block';
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
emptyMsg.style.display = 'none';
|
emptyMsg.style.display = 'none';
|
||||||
|
document.getElementById('chartContainer').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const [year, month, day] = dateStr.split('-');
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
|
||||||
@@ -314,21 +324,31 @@
|
|||||||
const res = await fetch(`cameras/${cameraId}/${year}/${month}/${day}`);
|
const res = await fetch(`cameras/${cameraId}/${year}/${month}/${day}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!isAutoUpdate) {
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (data.images.length === 0) {
|
if (data.images.length === 0) {
|
||||||
emptyMsg.style.display = 'block';
|
if (!isAutoUpdate) emptyMsg.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For auto-update, assume we have data now, clear grid to rebuild
|
||||||
|
if (isAutoUpdate) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Group by hour
|
// Group by hour
|
||||||
const hours = {};
|
const hours = {};
|
||||||
data.images.forEach(img => {
|
data.images.forEach(img => {
|
||||||
let h = '00';
|
let h = '00';
|
||||||
try {
|
try {
|
||||||
const match = img.timestamp.match(/(\d{2})[-:](\d{2})[-:](\d{2})/);
|
// Parse UTC timestamp from filename and convert to local time
|
||||||
if (match) {
|
const parts = img.timestamp.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
|
||||||
h = match[1];
|
if (parts) {
|
||||||
|
const [_, y, m, d, hUtc, mUtc, sUtc] = parts;
|
||||||
|
const date = new Date(Date.UTC(y, m - 1, d, hUtc, mUtc, sUtc));
|
||||||
|
h = String(date.getHours()).padStart(2, '0');
|
||||||
} else {
|
} else {
|
||||||
const d = new Date(img.timestamp);
|
const d = new Date(img.timestamp);
|
||||||
if (!isNaN(d.getTime())) h = String(d.getHours()).padStart(2, '0');
|
if (!isNaN(d.getTime())) h = String(d.getHours()).padStart(2, '0');
|
||||||
@@ -347,10 +367,11 @@
|
|||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
card.dataset.idx = 0; // Current viewed index
|
const lastIdx = group.length - 1;
|
||||||
|
card.dataset.idx = lastIdx; // Default to last (newest)
|
||||||
|
|
||||||
const thumb = document.createElement('img');
|
const thumb = document.createElement('img');
|
||||||
thumb.src = group[0].thumbnailUrl;
|
thumb.src = group[lastIdx].thumbnailUrl;
|
||||||
|
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'card-info';
|
info.className = 'card-info';
|
||||||
@@ -375,7 +396,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
card.onmouseleave = () => {
|
card.onmouseleave = () => {
|
||||||
// Optional: could reset here
|
const lastIdx = group.length - 1;
|
||||||
|
if (card.dataset.idx != lastIdx) {
|
||||||
|
thumb.src = group[lastIdx].thumbnailUrl;
|
||||||
|
card.dataset.idx = lastIdx;
|
||||||
|
info.textContent = `${hour}:00 (${group.length} pics)`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
card.onclick = () => {
|
card.onclick = () => {
|
||||||
@@ -388,16 +414,116 @@
|
|||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw brightness chart if stats exist
|
||||||
|
if (data.stats && data.stats.length > 0) {
|
||||||
|
document.getElementById('chartContainer').style.display = 'block';
|
||||||
|
drawChart(data.stats);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
console.error(err);
|
console.error(err);
|
||||||
grid.innerHTML = '<div style="color:red">Error loading images</div>';
|
grid.innerHTML = '<div style="color:red">Error loading images</div>';
|
||||||
|
document.getElementById('chartContainer').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(stats) {
|
||||||
|
const canvas = document.getElementById('brightnessChart');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Resize canvas to match display size
|
||||||
|
const rect = canvas.parentNode.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width;
|
||||||
|
canvas.height = 200;
|
||||||
|
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Sort stats by timestamp just in case
|
||||||
|
stats.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
|
||||||
|
// Helper to parsing time to minutes from start of day (local time)
|
||||||
|
const getMinutes = (iso) => {
|
||||||
|
// Parse UCT timestamp to Date object
|
||||||
|
const parts = iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
|
||||||
|
let d;
|
||||||
|
if (parts) {
|
||||||
|
const [_, y, m, day, hUtc, mUtc, sUtc] = parts;
|
||||||
|
d = new Date(Date.UTC(y, m - 1, day, hUtc, mUtc, sUtc));
|
||||||
|
} else {
|
||||||
|
d = new Date(iso);
|
||||||
|
}
|
||||||
|
return d.getHours() * 60 + d.getMinutes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const points = stats.map(s => ({
|
||||||
|
x: getMinutes(s.timestamp),
|
||||||
|
y: s.brightness
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Scales
|
||||||
|
const mapX = (minutes) => padding.left + (minutes / 1440) * (width - padding.left - padding.right);
|
||||||
|
const mapY = (val) => height - padding.bottom - (val / 255) * (height - padding.top - padding.bottom);
|
||||||
|
|
||||||
|
// Draw Axes
|
||||||
|
ctx.strokeStyle = '#555';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
// X-Axis
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, height - padding.bottom);
|
||||||
|
ctx.lineTo(width - padding.right, height - padding.bottom);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Y-Axis
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding.left, padding.top);
|
||||||
|
ctx.lineTo(padding.left, height - padding.bottom);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw Line
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
ctx.moveTo(mapX(points[0].x), mapY(points[0].y));
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
ctx.lineTo(mapX(points[i].x), mapY(points[i].y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw Labels (Time)
|
||||||
|
ctx.fillStyle = '#ccc';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
for (let h = 0; h <= 24; h += 4) {
|
||||||
|
const x = mapX(h * 60);
|
||||||
|
ctx.fillText(`${h}:00`, x, height - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Labels (Brightness)
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (let b = 0; b <= 255; b += 64) {
|
||||||
|
const y = mapY(b);
|
||||||
|
ctx.fillText(Math.round(b), padding.left - 5, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrettyTime(ts) {
|
function getPrettyTime(ts) {
|
||||||
const parts = ts.match(/(\d{2})[-:](\d{2})[-:](\d{2})/);
|
const parts = ts.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/);
|
||||||
if (parts) return `${parts[1]}:${parts[2]}:${parts[3]}`;
|
if (parts) {
|
||||||
|
const [_, y, m, d, hUtc, mUtc, sUtc] = parts;
|
||||||
|
const date = new Date(Date.UTC(y, m - 1, d, hUtc, mUtc, sUtc));
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
return ts;
|
return ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +550,14 @@
|
|||||||
if (state.selectedDate) loadImages();
|
if (state.selectedDate) loadImages();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-update every 60 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
if (state.selectedCamera && state.selectedDate) {
|
||||||
|
// Only auto-update if we are looking at something
|
||||||
|
loadImages(true);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
init();
|
init();
|
||||||
|
|
||||||
|
|||||||
49
server.js
49
server.js
@@ -116,14 +116,43 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
|||||||
|
|
||||||
// Generate thumbnail
|
// Generate thumbnail
|
||||||
try {
|
try {
|
||||||
|
const image = sharp(req.file.path);
|
||||||
|
const metadata = await image.metadata(); // Get metadata first
|
||||||
|
const stats = await image.stats(); // Get stats (includes mean for channels)
|
||||||
|
|
||||||
|
// Calculate Average Brightness
|
||||||
|
// Simple average of R, G, B mean values (0-255)
|
||||||
|
const brightness = Math.round((stats.channels[0].mean + stats.channels[1].mean + stats.channels[2].mean) / 3);
|
||||||
|
|
||||||
const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif');
|
const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif');
|
||||||
const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename);
|
const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename);
|
||||||
|
const dirPath = path.dirname(req.file.path);
|
||||||
|
|
||||||
await sharp(req.file.path)
|
await image
|
||||||
.resize(320) // Resize to 320px width, auto height
|
.resize(320) // Resize to 320px width, auto height
|
||||||
.toFormat('avif')
|
.toFormat('avif')
|
||||||
.toFile(thumbnailPath);
|
.toFile(thumbnailPath);
|
||||||
|
|
||||||
|
// Update daily stats.json
|
||||||
|
const statsFile = path.join(dirPath, 'stats.json');
|
||||||
|
let dailyStats = [];
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(statsFile)) {
|
||||||
|
dailyStats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error reading stats.json', e); }
|
||||||
|
|
||||||
|
dailyStats.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
filename: req.file.filename,
|
||||||
|
brightness
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write back stats
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(statsFile, JSON.stringify(dailyStats, null, 2));
|
||||||
|
} catch (e) { console.error('Error writing stats.json', e); }
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
cameraId,
|
cameraId,
|
||||||
@@ -132,17 +161,18 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
|
|||||||
thumbnail: thumbnailFilename,
|
thumbnail: thumbnailFilename,
|
||||||
thumbnailPath: thumbnailPath,
|
thumbnailPath: thumbnailPath,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
|
brightness,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Thumbnail generation failed:', error);
|
console.error('Processing failed:', error);
|
||||||
// Still return success for the upload even if thumbnail fails
|
// Still return success for the upload even if processing fails
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
cameraId,
|
cameraId,
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
thumbnailError: error.message,
|
error: error.message,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
@@ -378,7 +408,16 @@ app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => {
|
|||||||
// Sort by filename (timestamp) descending
|
// Sort by filename (timestamp) descending
|
||||||
images.sort((a, b) => b.filename.localeCompare(a.filename));
|
images.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||||
|
|
||||||
res.json({ images });
|
// Load stats if available
|
||||||
|
let stats = [];
|
||||||
|
try {
|
||||||
|
const statsFile = path.join(datePath, 'stats.json');
|
||||||
|
if (fs.existsSync(statsFile)) {
|
||||||
|
stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error reading stats.json', e); }
|
||||||
|
|
||||||
|
res.json({ images, stats });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to list images', details: error.message });
|
res.status(500).json({ error: 'Failed to list images', details: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user