From 4ddbca1246e0800d44b6a045e788587318984f50 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 19 Dec 2025 16:43:20 +0100 Subject: [PATCH] feat: Add backfill script for image brightness stats and display them in the UI. --- backfill_stats.js | 112 ++++++++++++++++++++++++++++++++ public/index.html | 162 ++++++++++++++++++++++++++++++++++++++++++---- server.js | 49 ++++++++++++-- 3 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 backfill_stats.js diff --git a/backfill_stats.js b/backfill_stats.js new file mode 100644 index 0000000..78b7a37 --- /dev/null +++ b/backfill_stats.js @@ -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); +}); diff --git a/public/index.html b/public/index.html index 187e4a0..165f021 100644 --- a/public/index.html +++ b/public/index.html @@ -173,6 +173,13 @@
Loading images...
Select a camera and date to view images
+ + + @@ -292,7 +299,7 @@ } } - async function loadImages() { + async function loadImages(isAutoUpdate = false) { const cameraId = cameraSelect.value; const dateStr = dateSelect.value; // YYYY-MM-DD @@ -304,9 +311,12 @@ localStorage.setItem('lastCamera', cameraId); localStorage.setItem('lastDate', dateStr); - loader.style.display = 'block'; - grid.innerHTML = ''; - emptyMsg.style.display = 'none'; + if (!isAutoUpdate) { + loader.style.display = 'block'; + grid.innerHTML = ''; + emptyMsg.style.display = 'none'; + document.getElementById('chartContainer').style.display = 'none'; + } const [year, month, day] = dateStr.split('-'); @@ -314,21 +324,31 @@ const res = await fetch(`cameras/${cameraId}/${year}/${month}/${day}`); const data = await res.json(); - loader.style.display = 'none'; + if (!isAutoUpdate) { + loader.style.display = 'none'; + } if (data.images.length === 0) { - emptyMsg.style.display = 'block'; + if (!isAutoUpdate) emptyMsg.style.display = 'block'; return; } + // For auto-update, assume we have data now, clear grid to rebuild + if (isAutoUpdate) { + grid.innerHTML = ''; + } + // Group by hour const hours = {}; data.images.forEach(img => { let h = '00'; try { - const match = img.timestamp.match(/(\d{2})[-:](\d{2})[-:](\d{2})/); - if (match) { - h = match[1]; + // Parse UTC timestamp from filename and convert to local time + const parts = img.timestamp.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/); + 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 { const d = new Date(img.timestamp); if (!isNaN(d.getTime())) h = String(d.getHours()).padStart(2, '0'); @@ -347,10 +367,11 @@ const card = document.createElement('div'); 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'); - thumb.src = group[0].thumbnailUrl; + thumb.src = group[lastIdx].thumbnailUrl; const info = document.createElement('div'); info.className = 'card-info'; @@ -375,7 +396,12 @@ }; 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 = () => { @@ -388,16 +414,116 @@ 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) { loader.style.display = 'none'; console.error(err); grid.innerHTML = '
Error loading images
'; + 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) { - const parts = ts.match(/(\d{2})[-:](\d{2})[-:](\d{2})/); - if (parts) return `${parts[1]}:${parts[2]}:${parts[3]}`; + const parts = ts.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2})[-:](\d{2})[-:](\d{2})/); + 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; } @@ -424,6 +550,14 @@ 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 init(); diff --git a/server.js b/server.js index b3a9e52..7bc16d5 100644 --- a/server.js +++ b/server.js @@ -116,14 +116,43 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => { // Generate thumbnail 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 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 .toFormat('avif') .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({ success: true, cameraId, @@ -132,17 +161,18 @@ app.post('/upload', authenticate, upload.single('image'), async (req, res) => { thumbnail: thumbnailFilename, thumbnailPath: thumbnailPath, size: req.file.size, + brightness, timestamp: new Date().toISOString() }); } catch (error) { - console.error('Thumbnail generation failed:', error); - // Still return success for the upload even if thumbnail fails + console.error('Processing failed:', error); + // Still return success for the upload even if processing fails res.json({ success: true, cameraId, filename: req.file.filename, path: req.file.path, - thumbnailError: error.message, + error: error.message, size: req.file.size, timestamp: new Date().toISOString() }); @@ -378,7 +408,16 @@ app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => { // Sort by filename (timestamp) descending 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) { res.status(500).json({ error: 'Failed to list images', details: error.message }); }