<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[lightpollutionmap.io]]></title><description><![CDATA[lightpollutionmap.io]]></description><link>https://lightpollutionmapio.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a8cfe2c99e7c38b8c06d82c/45e13ffa-5ef3-4ee0-9643-71aeef486c2a.png</url><title>lightpollutionmap.io</title><link>https://lightpollutionmapio.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 15:06:43 GMT</lastBuildDate><atom:link href="https://lightpollutionmapio.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Turning satellite night-light radiance into a Bortle number — and the binning mistake that turned my map black]]></title><description><![CDATA[I build lightpollutionmap.io, a light pollution map you can click anywhere on to get a Bortle class and a rough sky-brightness (SQM) value. The input to all of that is a single raster: NOAA's VIIRS Da]]></description><link>https://lightpollutionmapio.hashnode.dev/turning-satellite-night-light-radiance-into-a-bortle-number-and-the-binning-mistake-that-turned-my-map-black</link><guid isPermaLink="true">https://lightpollutionmapio.hashnode.dev/turning-satellite-night-light-radiance-into-a-bortle-number-and-the-binning-mistake-that-turned-my-map-black</guid><category><![CDATA[Geospatial]]></category><category><![CDATA[Earth Engine]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[data visualization]]></category><category><![CDATA[buildinpublic]]></category><dc:creator><![CDATA[Morning]]></dc:creator><pubDate>Wed, 02 Sep 2026 03:37:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8cfe2c99e7c38b8c06d82c/867e526c-beff-4e72-bd08-1087d7237834.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I build <a href="http://lightpollutionmap.io">lightpollutionmap.io</a>, a light pollution map you can click anywhere on to get a Bortle class and a rough sky-brightness (SQM) value. The input to all of that is a single raster: NOAA's VIIRS Day/Night Band annual composite, which measures upward artificial-light radiance for every ~500 m pixel on Earth.</p>
<p>Getting from "radiance in nW/cm²/sr" to "Bortle 4" is two conversions and one classic mistake. This post is the mistake.</p>
<h2>Step 1: radiance → SQM</h2>
<p>VIIRS radiance spans an absurd dynamic range. A genuinely dark rural pixel might read around 0.1–0.3; a suburban street sits in the single digits to low teens; the core of a big city can be 100 or more. You cannot map that linearly onto anything a human reads.</p>
<p>So the map uses a deliberately simplified logarithmic fit — roughly:</p>
<pre><code class="language-plaintext">sqm ≈ 22.0 - 2.0 * log10(radiance + 1)
sqm = clamp(sqm, 16.0, 22.0)
</code></pre>
<p>The <code>+1</code> keeps the log defined at zero radiance. The clamp reflects reality: you don't get darker than ~22 mag/arcsec² at a truly pristine site, and city cores flatten out around 16–17 for viewing purposes.</p>
<p>This is <strong>not</strong> a radiative-transfer model. The real reference for ground-level sky brightness — Falchi &amp; Cinzano's <em>World Atlas</em> — runs the satellite data through atmospheric scattering physics. This is curve-fitting tuned to roughly agree with ground measurements. It's good enough to answer "is it darker an hour north of here"; it is not good enough to cite in a paper. I'll come back to why that distinction matters.</p>
<h2>Step 2: SQM → Bortle class</h2>
<p>The Bortle scale is 9 buckets. Each class boundary corresponds to an SQM threshold — Class 1 is roughly 21.9+, the Class 4/5 boundary sits around 20.5, Class 8 is down near 18, and so on. So step 2 is just: find which bucket the SQM value falls into.</p>
<p>For the location-detail panel that's the whole story. The trouble started when I tried to render the same idea as a <em>map layer</em>.</p>
<h2>The binning mistake</h2>
<p>One of the color styles on the map is meant to look like contour bands — clean, discrete zones with hard edges, not a smooth gradient. To get that, before handing the image to the visualizer I discretize it into 9 steps.</p>
<p>My first version discretized with a <strong>uniform step</strong>:</p>
<pre><code class="language-plaintext">step = (max - min) / 9
band = image.subtract(min).divide(step).floor().clamp(0, 8)
</code></pre>
<p>With <code>min = 0</code> and <code>max = 150</code> (chosen to cover the brightest city cores), that puts the first band's ceiling at <strong>16.7</strong>.</p>
<p>Zoom the map out to continent scale and almost everything goes black.</p>
<p>Here's why. The radiance→SQM relationship is logarithmic <em>specifically because</em> radiance is log-distributed. Most inhabited-but-not-blazing places — small towns, exurbs, anywhere with streetlights but no skyline — sit in the single digits to low teens of radiance. Under a uniform split with a ceiling of 16.7, all of that collapses into band 0, the darkest, rendered black. Only genuine city cores clear the first threshold. The result looks like a black planet with a few glowing dots, which is not what the data actually says.</p>
<p>Uniform binning of a log-distributed quantity throws away all the resolution exactly where most of your users live.</p>
<h2>The fix: bin on real Bortle thresholds</h2>
<p>Instead of slicing the value range evenly, I went back to the SQM thresholds the Bortle scale already defines, and converted each one back into a radiance breakpoint with the inverse of step 1:</p>
<pre><code class="language-plaintext">BORTLE_RADIANCE_BREAKPOINTS = BORTLE_SQM_THRESHOLDS.map(sqmToRadiance)
</code></pre>
<p>These breakpoints are naturally dense at the dark end — the radiance gap between Bortle 1 and Bortle 4 is only a few units — and wide at the bright end, where an extra 50 nW barely changes what you can see. That matches how the underlying signal is distributed.</p>
<p>Then classify each pixel by counting how many breakpoints it clears:</p>
<pre><code class="language-plaintext">band = breakpoints
  .map(b =&gt; image.gte(b))   // one boolean layer per breakpoint
  .reduce(ee.Reducer.sum()) // 0..8 = the class index
</code></pre>
<p>Summing a stack of <code>gte</code> booleans to get a bucket index is a standard trick in Earth Engine — no loops, no server-side iteration. Now a small town with radiance around 8 lands in a middle band with a visible color instead of being quantized to black, and the continent-scale view actually shows the gradient that's in the data.</p>
<p>One more detail: discretize <em>after</em> a light smoothing pass (<code>focal_mean</code> over a few pixels), not before. Pixel-level noise, quantized directly, produces confetti — each noisy pixel snaps to a different band. Smooth the continuous signal first, then bin.</p>
<h2>The lesson, and why the methodology page exists</h2>
<p>The generalizable takeaway: <strong>before you bucket a continuous physical quantity, check whether the underlying distribution is linear.</strong> If it's log-distributed — radiance, sound intensity, a lot of natural signals — uniform bins will crush detail at one end. Bin on domain-meaningful breakpoints instead.</p>
<p>The specific takeaway is that every number this map shows you is the output of a chain of approximations: a simplified radiance→SQM fit, a threshold table for Bortle, a binning choice for the map layer, an annual composite that's over a year old. None of it is a measurement.</p>
<p>Rather than bury that, I wrote up every step of that conversion on a separate methodology page — what the satellite input is, that the brightness model is a simplified log formula rather than an atmospheric one, and that the Bortle class is derived from a modeled value, not observed in the field. If you're going to show people an estimate they'll make decisions on, the honest move is to show your work.</p>
]]></content:encoded></item></channel></rss>