Linearly bin fires so weak heat spots stay round

Dropping each detection whole into its nearest pixel snapped spots by up
to half a pixel and left weak lone fires as squares or plus signs once
thresholded. Share each detection's weight between the four nearest pixel
centres instead (linear binning, Wand 1994): spots sit at the true
position and blur round, at the same build cost.
This commit is contained in:
German Khokhlov committed 2026-11-03 14:39:02 +04:00
1 parent 5ab8ddb4ca
commit ef5c034dc9
3 files changed
+52 -18

No files matched your search

+3 -1
View File
@@ -2184,7 +2184,9 @@ detections are under 20 MW and stay dim red. Glow sprites and fire-card
accents read that ramp; confidence and brightness do not affect the color. accents read that ramp; confidence and brightness do not affect the color.
At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style
heatmap instead of individual markers: the shown detections are binned on a heatmap instead of individual markers: the shown detections are linearly binned
(each detection's weight shared between the four nearest pixel centres, so spots
sit at the true position and stay round) on a
global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is
radiated power, and raising the FRP slider does not dim areas where strong fires radiated power, and raising the FRP slider does not dim areas where strong fires
burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about
+29 -12
View File
@@ -1,8 +1,9 @@
/** /**
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections * Snap Map–style heat raster for the Active Fires far-zoom bands: detections
* are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), * are linearly binned on an equirectangular grid (row 0 = north, column
* blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: * 0 = 180°W), blurred, normalized on a log scale and mapped through
* no Cesium, no DOM, so the whole pipeline is unit-tested in node. * HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
* in node.
*/ */
/** Below this normalized heat nothing is painted: empty land stays empty. */ /** Below this normalized heat nothing is painted: empty land stays empty. */
@@ -54,19 +55,35 @@ export const HEAT_MIN_WEIGHT_MW = 0.5;
const defaultWeight = (fire) => const defaultWeight = (fire) =>
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
/**
* Linear binning (Wand 1994): each detection's weight is shared between the
* four pixel centres around it in proportion to closeness, instead of
* dropping whole into one pixel. Spots then sit at the true position rather
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
* into a round spot instead of a square or a plus sign. Longitude wraps;
* rows beyond the poles fold back onto the edge row.
*/
export function binFires(fires, { width, height, weight = defaultWeight }) { export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height); const grid = new Float32Array(width * height);
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
const col = (x) => ((x % width) + width) % width;
for (const fire of fires) { for (const fire of fires) {
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
const x = Math.min( const fx = ((fire.lon + 180) / 360) * width - 0.5;
width - 1, const fy = ((90 - fire.lat) / 180) * height - 0.5;
Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), const x0 = Math.floor(fx);
); const y0 = Math.floor(fy);
const y = Math.min( const tx = fx - x0;
height - 1, const ty = fy - y0;
Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), const w = weight(fire);
); const top = row(y0);
grid[y * width + x] += weight(fire); const bottom = row(y0 + 1);
const left = col(x0);
const right = col(x0 + 1);
grid[top + left] += w * (1 - tx) * (1 - ty);
grid[top + right] += w * tx * (1 - ty);
grid[bottom + left] += w * (1 - tx) * ty;
grid[bottom + right] += w * tx * ty;
} }
return grid; return grid;
} }
+20 -5
View File
@@ -28,22 +28,37 @@ test('palette runs ember to white-hot and starts fully transparent', () => {
} }
}); });
test('binFires drops each detection into its equirectangular pixel', () => { test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { // 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
width: 4, width: 4,
height: 2, height: 2,
}); });
assert.equal(grid[0], 1, 'north-west corner'); assert.equal(grid[0], 1, 'north-west pixel');
assert.equal(grid[7], 1, 'south-east corner'); assert.equal(grid[7], 1, 'south-east pixel');
assert.equal( assert.equal(
grid.reduce((a, b) => a + b, 0), grid.reduce((a, b) => a + b, 0),
2, 2,
); );
}); });
test('binFires shares a detection between the pixels around it', () => {
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
assert.equal(between[1], 0.5, 'half to the pixel west of it');
assert.equal(between[2], 0.5, 'half to the pixel east of it');
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
assert.equal(quarter[0], 0.75, 'closer centre takes more');
assert.equal(quarter[1], 0.25);
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
assert.equal(wrapped[0], 0.5);
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
});
test('binFires weights each detection by its radiative power', () => { test('binFires weights each detection by its radiative power', () => {
const grid = binFires( const grid = binFires(
[fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], [fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
{ width: 4, height: 2 }, { width: 4, height: 2 },
); );
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);