MoonFlash / detector.js
AK51's picture
Upload 10 files
1d377f1 verified
Raw History Blame Contribute Delete
16 kB
/*
* 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);