/* * detector.js * Client-side lunar impact flash detection engine. * * This logic was tuned against real footage (a single confirmed flash at * ~87.07 s / frame 2612 in a 30 fps clip) using an OpenCV reference port, and * chosen to isolate that one genuine flash out of tens of thousands of raw * brightening events caused by handheld jitter and sunlit-limb shimmer. * * Pipeline: * 1. Downscale each frame to an analysis buffer (averages sensor noise). * 2. Rolling background = mean of the previous N frames. * 3. diff = luma - background. * 4. TOP-HAT: subtract a blurred copy of diff so only spatially COMPACT * peaks survive. Broad brightness shifts from camera jitter/glow blur * away and are rejected — this is what removes the "false positives * everywhere" problem. * 5. Threshold -> connected-component blobs (size filtered). * 6. Link blobs across frames into events. * 7. Report an event as a flash only if it is: a genuine short transient * (rises then fades), compact/round (high fill), within the size and * duration limits, and its peak amplitude clears the confidence floor. * * A lightweight Moon-disc locator is kept ONLY for drawing the tracking * ellipse in the overlay; detection no longer depends on it. * * Everything runs in the browser; the video never leaves the machine. */ (function (global) { "use strict"; // Base diff threshold (0..255) by sensitivity. Higher sensitivity => lower. const SENSITIVITY_THRESHOLDS = { 1: 46, 2: 42, 3: 38, 4: 32, 5: 26 }; // Confidence floor on peak diff amplitude (0..255) by sensitivity. A real // impact flash is a sharp, high-amplitude spike; limb shimmer is much lower. // Tuned so the validated sample yields exactly one flash with wide margin. const CONFIDENCE_PEAKDIFF = { 1: 180, 2: 160, 3: 140, 4: 120, 5: 100 }; const ANALYSIS_WIDTH = 480; const BG_HISTORY = 15; // Blob shape/size gates (analysis-space). const MIN_FILL = 0.35; // area / bbox-area: rejects thin jitter streaks const MAX_AREA_ANALYSIS = 400; // point-like flashes are compact // Impact flashes are point sources (aspect ~1). Sunlit-limb shimmer forms // thin streaks ALONG the edge (aspect >> 1). Reject anything too elongated. const MAX_ASPECT = 2.2; class FlashDetector { constructor(opts) { this.opts = Object.assign( { sensitivity: 3, minSize: 2, maxSize: 1500, minDurationMs: 0, maxDurationMs: 800 }, opts || {} ); this.threshold = SENSITIVITY_THRESHOLDS[this.opts.sensitivity] || 38; this.confidence = CONFIDENCE_PEAKDIFF[this.opts.sensitivity] || 135; this.w = 0; this.h = 0; this.scale = 1; this.bgSum = null; this.bgCount = 0; this.history = []; this.disc = null; this.lastDisc = null; this.activeEvents = []; this.finishedEvents = []; this._eventId = 0; // scratch buffers (allocated in initGeometry) this._diff = null; this._blur = null; this._tmp = null; this._hot = null; } initGeometry(sourceWidth, sourceHeight) { this.scale = sourceWidth > ANALYSIS_WIDTH ? sourceWidth / ANALYSIS_WIDTH : 1; this.w = Math.max(1, Math.round(sourceWidth / this.scale)); this.h = Math.max(1, Math.round(sourceHeight / this.scale)); const n = this.w * this.h; this.bgSum = new Float32Array(n); this.bgCount = 0; this.history = []; this.disc = null; this.lastDisc = null; this.activeEvents = []; this.finishedEvents = []; this._diff = new Float32Array(n); this._blur = new Float32Array(n); this._tmp = new Float32Array(n); this._hot = new Uint8Array(n); } get analysisWidth() { return this.w; } get analysisHeight() { return this.h; } _toLuma(imageData) { const { data } = imageData; const n = this.w * this.h; const luma = new Float32Array(n); for (let i = 0, p = 0; i < n; i++, p += 4) { luma[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2]; } return luma; } /* ---- Moon disc locator (overlay only) ------------------------------- */ _locateMoon(luma) { const n = this.w * this.h; // Bright, adaptive cut. The Moon is the dominant bright blob. let maxv = 0; for (let i = 0; i < n; i++) if (luma[i] > maxv) maxv = luma[i]; const cut = Math.max(50, maxv * 0.35); let count = 0, sx = 0, sy = 0; let minX = this.w, minY = this.h, maxX = 0, maxY = 0; for (let y = 0; y < this.h; y++) { const row = y * this.w; for (let x = 0; x < this.w; x++) { if (luma[row + x] >= cut) { count++; sx += x; sy += y; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (count < 30) return null; const cx = sx / count, cy = sy / count; // Radius from bounding box (robust for a compact disc), not RMS spread. const r = Math.max((maxX - minX), (maxY - minY)) / 2 + 2; return { cx, cy, r }; } _updateDisc(luma) { const found = this._locateMoon(luma); if (found) { if (!this.disc) this.disc = found; else { const a = 0.3; this.disc = { cx: this.disc.cx + a * (found.cx - this.disc.cx), cy: this.disc.cy + a * (found.cy - this.disc.cy), r: this.disc.r + a * (found.r - this.disc.r), }; } } this.lastDisc = this.disc; } /* ---- fast separable box blur (approx Gaussian) ---------------------- */ _boxBlur(src, dst, radius) { const { w, h } = this; const tmp = this._tmp; const win = radius * 2 + 1; // horizontal for (let y = 0; y < h; y++) { const row = y * w; let acc = 0; for (let x = -radius; x <= radius; x++) { const xx = x < 0 ? 0 : (x >= w ? w - 1 : x); acc += src[row + xx]; } for (let x = 0; x < w; x++) { tmp[row + x] = acc / win; const xOut = x - radius; const xIn = x + radius + 1; const o = xOut < 0 ? 0 : (xOut >= w ? w - 1 : xOut); const i = xIn < 0 ? 0 : (xIn >= w ? w - 1 : xIn); acc += src[row + i] - src[row + o]; } } // vertical for (let x = 0; x < w; x++) { let acc = 0; for (let y = -radius; y <= radius; y++) { const yy = y < 0 ? 0 : (y >= h ? h - 1 : y); acc += tmp[yy * w + x]; } for (let y = 0; y < h; y++) { dst[y * w + x] = acc / win; const yOut = y - radius; const yIn = y + radius + 1; const o = yOut < 0 ? 0 : (yOut >= h ? h - 1 : yOut); const i = yIn < 0 ? 0 : (yIn >= h ? h - 1 : yIn); acc += tmp[i * w + x] - tmp[o * w + x]; } } } processFrame(imageData, frameIndex, timeSec) { const luma = this._toLuma(imageData); const n = this.w * this.h; this._updateDisc(luma); if (this.bgCount < 4) { this._pushBackground(luma); return { blobs: [], disc: this.disc }; } const diff = this._diff; for (let i = 0; i < n; i++) diff[i] = luma[i] - this.bgSum[i] / this.bgCount; // Top-hat: diff minus blurred diff -> keeps compact peaks only. this._boxBlur(diff, this._blur, 9); const tophat = this._tmp; // reuse tmp for tophat output for (let i = 0; i < n; i++) tophat[i] = diff[i] - this._blur[i]; // Robust noise from |tophat| median (sampled for speed). let med = this._sampleMedianAbs(tophat); const thTop = Math.max(this.threshold * 0.5, med * 6.0); const hot = this._hot; let hotCount = 0; for (let i = 0; i < n; i++) { if (diff[i] >= this.threshold && tophat[i] >= thTop) { hot[i] = 1; hotCount++; } else hot[i] = 0; } let blobs = []; if (hotCount > 0 && hotCount < n * 0.15) { blobs = this._connectedComponents(hot, diff, luma); } this._pushBackground(luma); this._linkEvents(blobs, frameIndex, timeSec); return { blobs, disc: this.disc }; } _sampleMedianAbs(arr) { const n = arr.length; const step = Math.max(1, Math.floor(n / 4000)); const s = []; for (let i = 0; i < n; i += step) s.push(Math.abs(arr[i])); if (!s.length) return 4; s.sort((a, b) => a - b); return Math.max(1, s[Math.floor(s.length / 2)]); } _pushBackground(luma) { const n = luma.length; if (this.history.length >= BG_HISTORY) { const old = this.history.shift(); for (let i = 0; i < n; i++) this.bgSum[i] -= old[i]; this.bgCount--; } for (let i = 0; i < n; i++) this.bgSum[i] += luma[i]; this.history.push(luma); this.bgCount++; } _connectedComponents(hot, diff, luma) { const { w, h } = this; const n = w * h; const labels = new Int32Array(n).fill(-1); const stack = []; const blobs = []; const { minSize, maxSize } = this.opts; for (let start = 0; start < n; start++) { if (!hot[start] || labels[start] !== -1) continue; stack.length = 0; stack.push(start); labels[start] = blobs.length; let area = 0, sumX = 0, sumY = 0, sumW = 0; let minX = w, minY = h, maxX = 0, maxY = 0; let peakDiff = 0, peakLuma = 0; while (stack.length) { const idx = stack.pop(); const x = idx % w; const y = (idx - x) / w; const weight = diff[idx]; area++; sumX += x * weight; sumY += y * weight; sumW += weight; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; if (diff[idx] > peakDiff) peakDiff = diff[idx]; if (luma[idx] > peakLuma) peakLuma = luma[idx]; for (let dy = -1; dy <= 1; dy++) { const yy = y + dy; if (yy < 0 || yy >= h) continue; for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const xx = x + dx; if (xx < 0 || xx >= w) continue; const nIdx = yy * w + xx; if (hot[nIdx] && labels[nIdx] === -1) { labels[nIdx] = blobs.length; stack.push(nIdx); } } } } if (area < minSize || area > maxSize) continue; const bw = maxX - minX + 1, bh = maxY - minY + 1; const fill = area / Math.max(1, bw * bh); // Elongation = longer side / shorter side. An impact flash is a point // source (aspect ~1). Sunlit-limb shimmer is a thin streak along the // edge (aspect >> 1), which is exactly what we want to reject. const aspect = Math.max(bw, bh) / Math.max(1, Math.min(bw, bh)); const cx = sumW > 0 ? sumX / sumW : (minX + maxX) / 2; const cy = sumW > 0 ? sumY / sumW : (minY + maxY) / 2; blobs.push({ cx, cy, minX, minY, maxX, maxY, area, fill, aspect, peakDiff, peakLuma }); } return blobs; } _linkEvents(blobs, frameIndex, timeSec) { const linkRadius = Math.max(6, this.w * 0.025); const used = new Array(blobs.length).fill(false); for (const ev of this.activeEvents) { let best = -1, bestDist = Infinity; for (let i = 0; i < blobs.length; i++) { if (used[i]) continue; const b = blobs[i]; const dist = Math.hypot(b.cx - ev.lastCx, b.cy - ev.lastCy); if (dist < bestDist && dist <= linkRadius) { bestDist = dist; best = i; } } if (best >= 0) { const b = blobs[best]; used[best] = true; ev.frames++; ev.lastFrame = frameIndex; ev.endTime = timeSec; ev.lastCx = b.cx; ev.lastCy = b.cy; ev.maxArea = Math.max(ev.maxArea, b.area); ev.maxAspect = Math.max(ev.maxAspect, b.aspect); ev.diffSeries.push(b.peakDiff); if (b.peakDiff > ev.peakDiff) { ev.peakDiff = b.peakDiff; ev.peakLuma = b.peakLuma; ev.peakCx = b.cx; ev.peakCy = b.cy; ev.peakArea = b.area; ev.peakFill = b.fill; ev.peakAspect = b.aspect; ev.peakBBox = [b.minX, b.minY, b.maxX, b.maxY]; ev.peakTime = timeSec; ev.peakFrame = frameIndex; } ev.miss = 0; } else ev.miss = (ev.miss || 0) + 1; } const stillActive = []; for (const ev of this.activeEvents) { if (ev.miss >= 1) this.finishedEvents.push(ev); else stillActive.push(ev); } this.activeEvents = stillActive; for (let i = 0; i < blobs.length; i++) { if (used[i]) continue; const b = blobs[i]; this.activeEvents.push({ id: ++this._eventId, startFrame: frameIndex, lastFrame: frameIndex, startTime: timeSec, endTime: timeSec, peakTime: timeSec, peakFrame: frameIndex, frames: 1, lastCx: b.cx, lastCy: b.cy, peakCx: b.cx, peakCy: b.cy, peakDiff: b.peakDiff, peakLuma: b.peakLuma, peakArea: b.area, peakFill: b.fill, peakAspect: b.aspect, maxAspect: b.aspect, maxArea: b.area, peakBBox: [b.minX, b.minY, b.maxX, b.maxY], diffSeries: [b.peakDiff], miss: 0, }); } } finalize(frameDurationSec) { for (const ev of this.activeEvents) this.finishedEvents.push(ev); this.activeEvents = []; const minDur = (this.opts.minDurationMs || 0) / 1000; const maxDur = (this.opts.maxDurationMs || 800) / 1000; const results = []; for (const ev of this.finishedEvents) { const durationSec = Math.max(ev.endTime - ev.startTime + frameDurationSec, frameDurationSec || 0); if (durationSec < minDur) continue; if (durationSec > maxDur) continue; if (ev.peakDiff < this.confidence) continue; // amplitude confidence if (ev.peakFill < MIN_FILL) continue; // compact/round only if (ev.peakArea > MAX_AREA_ANALYSIS) continue; // point-like // Reject elongated streaks (sunlit-limb / edge shimmer). Use the max // aspect seen across the event so a streak on ANY frame disqualifies it. if (Math.max(ev.peakAspect || 1, ev.maxAspect || 1) > MAX_ASPECT) continue; if (!this._isTransient(ev)) continue; // rise then fade const s = this.scale; const bbox = ev.peakBBox; results.push({ id: ev.id, startFrame: ev.startFrame, endFrame: ev.lastFrame, peakFrame: ev.peakFrame, startTime: ev.startTime, endTime: ev.endTime, peakTime: ev.peakTime, durationSec, frames: ev.frames, x: Math.round(ev.peakCx * s), y: Math.round(ev.peakCy * s), bboxW: Math.round((bbox[2] - bbox[0] + 1) * s), bboxH: Math.round((bbox[3] - bbox[1] + 1) * s), areaSrcPx: Math.round(ev.peakArea * s * s), peakBrightness: Math.round(ev.peakLuma), peakDelta: Math.round(ev.peakDiff), fill: Math.round(ev.peakFill * 100) / 100, aspect: Math.round(Math.max(ev.peakAspect || 1, ev.maxAspect || 1) * 100) / 100, _analysis: { cx: ev.peakCx, cy: ev.peakCy, bbox: bbox.slice() }, }); } results.sort((a, b) => a.startTime - b.startTime); results.forEach((r, i) => { r.index = i + 1; }); return results; } _isTransient(ev) { const s = ev.diffSeries; if (s.length <= 2) return true; const peakVal = Math.max.apply(null, s); const last = s[s.length - 1]; const first = s[0]; const decayed = last <= peakVal * 0.6; const rose = peakVal >= first * 1.15 || s.indexOf(peakVal) > 0; return decayed && rose; } } global.FlashDetector = FlashDetector; global.FLASH_ANALYSIS_WIDTH = ANALYSIS_WIDTH; })(typeof window !== "undefined" ? window : this);