Download detector.js from AK51/MoonFlash: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/AK51/MoonFlash/resolve/main/detector.js
- Command line
-
hf download hf://spaces/AK51/MoonFlash/detector.js
-
curl -L -o detector.js https://huggingface.co/spaces/AK51/MoonFlash/resolve/main/detector.js
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); | |