segment-imagery-footprints.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. // Segment the local high-resolution satellite imagery inside the confirmed residential masks into
  2. // building footprints, so generated city massing sits on satellite-visible roofs instead of being
  3. // inferred from roads.
  4. //
  5. // Deterministic: no randomness, fixed iteration order, rounded numbers.
  6. // Usage: node tools/city-massing/segment-imagery-footprints.mjs [maskId]
  7. // maskId is an optional debug filter that limits the run to one residential mask.
  8. import { readFileSync, writeFileSync, existsSync } from 'node:fs'
  9. import { resolve } from 'node:path'
  10. import { PNG } from 'pngjs'
  11. import jpeg from 'jpeg-js'
  12. import * as turf from '@turf/turf'
  13. import { isWithinPrimaryArea } from './primary-area.mjs'
  14. const IMAGERY_DIR = process.env.GIS_DIR ? `${process.env.GIS_DIR}/yuanlingPic3` : 'D:/GIS/GIS/yuanlingPic3'
  15. const CITY_DIR = resolve('tools/city-massing/city')
  16. const OUT_GEOJSON = resolve(CITY_DIR, 'imagery-footprints.geojson')
  17. const OUT_REPORT = resolve(CITY_DIR, 'imagery-footprints-report.json')
  18. const OUT_AUDIT = resolve(CITY_DIR, 'imagery-footprints-audit.png')
  19. const TILE = 256
  20. const Z = 19 // segmentation zoom, about 0.26 m/px at this latitude
  21. const Z_AUDIT = 18 // audit backdrop zoom, about 0.53 m/px (exactly half of Z)
  22. const AUDIT_MAX_SIDE = 2400 // panel cap: below this a 10 m building is still several pixels wide
  23. const MASK_FILTER = process.argv[2] ? Number(process.argv[2]) : null
  24. // Every threshold the run uses, echoed verbatim into the report.
  25. const THRESHOLDS = Object.freeze({
  26. greenMin: 8, // vegetation index g - max(r, b) must exceed this ...
  27. greenG: 55, // ... and g must exceed this
  28. waterBrightness: 90, // very dark ...
  29. waterBlueGreen: 14, // ... strongly blue-green ...
  30. waterTexture: 9, // ... and low-texture (5x5 brightness std below this)
  31. darkLightSplit: 95, // brightness separating the dark-roof and light-roof families
  32. edgeBarrier: 14, // Sobel gradient on blurred brightness above this blocks connectivity
  33. erodeRadius: 1, // px eroded so thin shadow gaps between neighbours split them
  34. minAreaSqm: 40,
  35. maxAreaSqm: 4000,
  36. minFillRatio: 0.55, // component area / minimum-area rotated rectangle area
  37. maxLongSideM: 90,
  38. minShortSideM: 4,
  39. ringRadiusPx: 3,
  40. maxRingVegetation: 0.5, // reject when the dilated ring is dominated by vegetation
  41. })
  42. const SOURCE = 'yuanlingPic3 z19 TMS local imagery, per-pixel classified then 8-connected segmented'
  43. // ---------------------------------------------------------------- projections
  44. const scale = 2 ** Z
  45. const lonToPx = (lon) => ((lon + 180) / 360) * scale * TILE
  46. const latToPy = (lat) => ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * scale * TILE
  47. const pxToLon = (x) => (x / (scale * TILE)) * 360 - 180
  48. const pyToLat = (y) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / (scale * TILE)))) * 180) / Math.PI
  49. /** Ground resolution in metres per pixel at a latitude. */
  50. const metresPerPixel = (lat) => (156543.03392 * Math.cos((lat * Math.PI) / 180)) / scale
  51. /** z/x/googleY tile range covering a WGS84 bbox at a zoom. */
  52. function tileRange(bbox, zoom) {
  53. const n = 2 ** zoom
  54. const xOf = (lon) => ((lon + 180) / 360) * n
  55. const yOf = (lat) => ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * n
  56. return { x0: Math.floor(xOf(bbox[0])), x1: Math.floor(xOf(bbox[2])), y0: Math.floor(yOf(bbox[3])), y1: Math.floor(yOf(bbox[1])) }
  57. }
  58. /** Read one TMS tile at `{z}/{x}/{tmsY}.png`; JPEG bytes when the first byte is 0xFF. */
  59. function readTile(zoom, x, googleY) {
  60. const tmsY = 2 ** zoom - 1 - googleY
  61. const path = `${IMAGERY_DIR}/${zoom}/${x}/${tmsY}.png`
  62. if (!existsSync(path)) return null
  63. const bytes = readFileSync(path)
  64. return { image: bytes[0] === 0xff ? jpeg.decode(bytes) : PNG.sync.read(bytes), ref: [zoom, x, tmsY] }
  65. }
  66. const round = (value, digits) => Number(value.toFixed(digits))
  67. // ------------------------------------------------------- classification raster
  68. /** Copy one decoded tile's RGB into a window-local plane, 3 bytes per pixel. */
  69. function blitTile(image, plane, winW, winH, ox, oy) {
  70. for (let y = 0; y < TILE; y++) {
  71. const wy = oy + y
  72. if (wy < 0 || wy >= winH) continue
  73. for (let x = 0; x < TILE; x++) {
  74. const wx = ox + x
  75. if (wx < 0 || wx >= winW) continue
  76. const k = (y * TILE + x) * 4
  77. const d = (wy * winW + wx) * 3
  78. const a = image.data[k + 3]
  79. plane[d] = a === 0 ? 0 : image.data[k]
  80. plane[d + 1] = a === 0 ? 0 : image.data[k + 1]
  81. plane[d + 2] = a === 0 ? 0 : image.data[k + 2]
  82. }
  83. }
  84. }
  85. /** Separable 3x3 box blur, borders clamped so window edges are not artificial features. */
  86. function boxBlur3(src, winW, winH) {
  87. const temp = new Uint8Array(winW * winH)
  88. for (let y = 0; y < winH; y++) {
  89. const row = y * winW
  90. for (let x = 0; x < winW; x++) {
  91. const a = src[row + (x > 0 ? x - 1 : 0)]
  92. const b = src[row + x]
  93. const c = src[row + (x < winW - 1 ? x + 1 : winW - 1)]
  94. temp[row + x] = (a + b + c) / 3
  95. }
  96. }
  97. const out = new Uint8Array(winW * winH)
  98. for (let y = 0; y < winH; y++) {
  99. const row = y * winW
  100. const up = (y > 0 ? y - 1 : 0) * winW
  101. const down = (y < winH - 1 ? y + 1 : winH - 1) * winW
  102. for (let x = 0; x < winW; x++) out[row + x] = (temp[up + x] + temp[row + x] + temp[down + x]) / 3
  103. }
  104. return out
  105. }
  106. /**
  107. * Per-pixel classification into 0 barrier, 1 vegetation, 2 water, 3 dark roof, 4 light roof.
  108. * The barrier class is an edge test, so two roofs separated by a bright gutter or a dark shadow
  109. * line do not fuse into one component even when both sides fall in the same brightness family.
  110. */
  111. function classify(rgb, blur, winW, winH) {
  112. const value = new Uint8Array(winW * winH)
  113. for (let y = 0; y < winH; y++) {
  114. for (let x = 0; x < winW; x++) {
  115. const i = y * winW + x
  116. const k = i * 3
  117. const r = rgb[k]
  118. const g = rgb[k + 1]
  119. const b = rgb[k + 2]
  120. const v = blur[i]
  121. if (g - Math.max(r, b) > THRESHOLDS.greenMin && g > THRESHOLDS.greenG) {
  122. value[i] = 1
  123. } else if (v < THRESHOLDS.waterBrightness && (g + b) / 2 - r > THRESHOLDS.waterBlueGreen && std5(blur, winW, winH, x, y) < THRESHOLDS.waterTexture) {
  124. value[i] = 2
  125. } else if (sobelMagnitude(blur, winW, winH, x, y) > THRESHOLDS.edgeBarrier) {
  126. value[i] = 0
  127. } else {
  128. value[i] = v < THRESHOLDS.darkLightSplit ? 3 : 4
  129. }
  130. }
  131. }
  132. return value
  133. }
  134. /** 5x5 standard deviation of brightness; water is smooth, roofs and vegetation are not. */
  135. function std5(plane, winW, winH, x, y) {
  136. let sum = 0
  137. let sumSq = 0
  138. let n = 0
  139. for (let dy = -2; dy <= 2; dy++) {
  140. const yy = y + dy
  141. if (yy < 0 || yy >= winH) continue
  142. for (let dx = -2; dx <= 2; dx++) {
  143. const xx = x + dx
  144. if (xx < 0 || xx >= winW) continue
  145. const v = plane[yy * winW + xx]
  146. sum += v
  147. sumSq += v * v
  148. n++
  149. }
  150. }
  151. const mean = sum / n
  152. return Math.sqrt(Math.max(0, sumSq / n - mean * mean))
  153. }
  154. /** Sobel gradient magnitude of the blurred brightness. */
  155. function sobelMagnitude(plane, winW, winH, x, y) {
  156. const at = (xx, yy) => plane[(yy < 0 ? 0 : yy >= winH ? winH - 1 : yy) * winW + (xx < 0 ? 0 : xx >= winW ? winW - 1 : xx)]
  157. const tl = at(x - 1, y - 1)
  158. const tc = at(x, y - 1)
  159. const tr = at(x + 1, y - 1)
  160. const ml = at(x - 1, y)
  161. const mr = at(x + 1, y)
  162. const bl = at(x - 1, y + 1)
  163. const bc = at(x, y + 1)
  164. const br = at(x + 1, y + 1)
  165. const gx = tr + 2 * mr + br - tl - 2 * ml - bl
  166. const gy = bl + 2 * bc + br - tl - 2 * tc - tr
  167. return Math.hypot(gx, gy) / 4
  168. }
  169. // ------------------------------------------------------------- polygon raster
  170. /** Even-odd scanline fill of a WGS84 polygon with holes onto the window pixel grid. */
  171. function rasterizePolygon(rings, px0, py0, winW, winH) {
  172. const raster = new Uint8Array(winW * winH)
  173. const edges = []
  174. for (const ring of rings) {
  175. for (let i = 1; i < ring.length; i++) {
  176. edges.push([lonToPx(ring[i - 1][0]) - px0, latToPy(ring[i - 1][1]) - py0, lonToPx(ring[i][0]) - px0, latToPy(ring[i][1]) - py0])
  177. }
  178. }
  179. const xs = []
  180. for (let y = 0; y < winH; y++) {
  181. const cy = y + 0.5
  182. xs.length = 0
  183. for (const [ax, ay, bx, by] of edges) {
  184. if (ay === by) continue
  185. if (cy < Math.min(ay, by) || cy >= Math.max(ay, by)) continue
  186. xs.push(ax + ((cy - ay) / (by - ay)) * (bx - ax))
  187. }
  188. if (xs.length < 2) continue
  189. xs.sort((a, b) => a - b)
  190. for (let i = 0; i + 1 < xs.length; i += 2) {
  191. const from = Math.max(0, Math.ceil(xs[i] - 0.5))
  192. const to = Math.min(winW - 1, Math.floor(xs[i + 1] - 0.5))
  193. for (let x = from; x <= to; x++) raster[y * winW + x] = 1
  194. }
  195. }
  196. return raster
  197. }
  198. // ------------------------------------------------------- connected components
  199. /** Shrink each roof family by `radius` px so thin shadow gaps split neighbouring buildings. */
  200. function erode(value, winW, winH, radius) {
  201. const out = new Uint8Array(winW * winH)
  202. for (let y = radius; y < winH - radius; y++) {
  203. for (let x = radius; x < winW - radius; x++) {
  204. const i = y * winW + x
  205. const c = value[i]
  206. if (c < 3) continue
  207. let keep = 1
  208. for (let dy = -radius; dy <= radius && keep; dy++) {
  209. const row = (y + dy) * winW
  210. for (let dx = -radius; dx <= radius; dx++) {
  211. if (value[row + x + dx] !== c) { keep = 0; break }
  212. }
  213. }
  214. out[i] = keep ? c : 0
  215. }
  216. }
  217. return out
  218. }
  219. /** Two-pass 8-connected labelling; returns one label per pixel (0 = background). */
  220. function labelComponents(mask, winW, winH) {
  221. const labels = new Int32Array(winW * winH)
  222. const parent = [0]
  223. const find = (v) => {
  224. while (parent[v] !== v) { parent[v] = parent[parent[v]]; v = parent[v] }
  225. return v
  226. }
  227. const union = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[Math.max(a, b)] = Math.min(a, b) }
  228. let next = 1
  229. for (let y = 0; y < winH; y++) {
  230. for (let x = 0; x < winW; x++) {
  231. const i = y * winW + x
  232. if (!mask[i]) continue
  233. let best = 0
  234. if (y > 0) {
  235. const up = i - winW
  236. for (let dx = -1; dx <= 1; dx++) {
  237. const xx = x + dx
  238. if (xx < 0 || xx >= winW) continue
  239. const l = labels[up + dx]
  240. if (l && (best === 0 || l < best)) best = l
  241. }
  242. }
  243. if (x > 0 && labels[i - 1] && (best === 0 || labels[i - 1] < best)) best = labels[i - 1]
  244. if (best === 0) { best = next; parent[next] = next; next++ }
  245. labels[i] = best
  246. if (y > 0) {
  247. const up = i - winW
  248. for (let dx = -1; dx <= 1; dx++) {
  249. const xx = x + dx
  250. if (xx < 0 || xx >= winW) continue
  251. const l = labels[up + dx]
  252. if (l && l !== best) union(l, best)
  253. }
  254. }
  255. if (x > 0 && labels[i - 1] && labels[i - 1] !== best) union(labels[i - 1], best)
  256. }
  257. }
  258. const remap = new Int32Array(next).fill(-1)
  259. let count = 0
  260. for (let i = 0; i < labels.length; i++) {
  261. if (!labels[i]) continue
  262. const root = find(labels[i])
  263. if (remap[root] < 0) remap[root] = ++count
  264. labels[i] = remap[root]
  265. }
  266. return { labels, count }
  267. }
  268. /**
  269. * Grow each label back out by `radius` px, but only onto pixels of its own family, so the erode
  270. * step does not permanently shrink footprints and neighbours cannot fuse back together.
  271. */
  272. function dilateLabels(labels, value, winW, winH, radius) {
  273. const out = Int32Array.from(labels)
  274. for (let y = 0; y < winH; y++) {
  275. for (let x = 0; x < winW; x++) {
  276. const i = y * winW + x
  277. if (labels[i] || value[i] < 3) continue
  278. let best = 0
  279. for (let dy = -radius; dy <= radius; dy++) {
  280. const yy = y + dy
  281. if (yy < 0 || yy >= winH) continue
  282. const row = yy * winW
  283. for (let dx = -radius; dx <= radius; dx++) {
  284. const xx = x + dx
  285. if (xx < 0 || xx >= winW) continue
  286. const l = labels[row + xx]
  287. if (l && value[row + xx] === value[i] && (best === 0 || l < best)) best = l
  288. }
  289. }
  290. out[i] = best
  291. }
  292. }
  293. return out
  294. }
  295. // ------------------------------------------------------------- geometry maths
  296. /** Convex hull (monotone chain) of pixel centres. */
  297. function convexHull(points) {
  298. const pts = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1])
  299. if (pts.length < 3) return pts
  300. const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
  301. const lower = []
  302. for (const p of pts) {
  303. while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop()
  304. lower.push(p)
  305. }
  306. const upper = []
  307. for (let i = pts.length - 1; i >= 0; i--) {
  308. const p = pts[i]
  309. while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop()
  310. upper.push(p)
  311. }
  312. lower.pop()
  313. upper.pop()
  314. return lower.concat(upper)
  315. }
  316. /** Minimum-area rotated rectangle around a pixel set, by rotating calipers over hull edges. */
  317. function minAreaRect(points) {
  318. const hull = convexHull(points)
  319. if (hull.length < 3) return null
  320. let best = null
  321. for (let i = 0; i < hull.length; i++) {
  322. const a = hull[i]
  323. const b = hull[(i + 1) % hull.length]
  324. const dx = b[0] - a[0]
  325. const dy = b[1] - a[1]
  326. const len = Math.hypot(dx, dy)
  327. if (len === 0) continue
  328. const ux = dx / len
  329. const uy = dy / len
  330. let minU = Infinity
  331. let maxU = -Infinity
  332. let minV = Infinity
  333. let maxV = -Infinity
  334. for (const p of hull) {
  335. const ox = p[0] - a[0]
  336. const oy = p[1] - a[1]
  337. const u = ox * ux + oy * uy
  338. const v = -ox * uy + oy * ux
  339. if (u < minU) minU = u
  340. if (u > maxU) maxU = u
  341. if (v < minV) minV = v
  342. if (v > maxV) maxV = v
  343. }
  344. const area = (maxU - minU) * (maxV - minV)
  345. if (!best || area < best.area) {
  346. best = { area, ux, uy, ax: a[0], ay: a[1], minU, maxU, minV, maxV, lenU: maxU - minU, lenV: maxV - minV }
  347. }
  348. }
  349. return best
  350. }
  351. /** The four corners of a rotated rectangle, in pixel space, counter-clockwise. */
  352. const rectCorners = (rect) =>
  353. [[rect.minU, rect.minV], [rect.maxU, rect.minV], [rect.maxU, rect.maxV], [rect.minU, rect.maxV]].map(([u, v]) => [
  354. rect.ax + u * rect.ux - v * rect.uy,
  355. rect.ay + u * rect.uy + v * rect.ux,
  356. ])
  357. // ------------------------------------------------------------- exclusion layers
  358. function loadLayer(name) {
  359. return JSON.parse(readFileSync(resolve(CITY_DIR, name), 'utf8')).features.map((feature) => ({ feature, bbox: turf.bbox(feature) }))
  360. }
  361. const bboxOverlaps = (a, b) => !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3])
  362. /** First layer entry the polygon touches, or null. Layers are bbox-prefiltered for speed. */
  363. function firstIntersection(polygon, bbox, layer) {
  364. for (const entry of layer) {
  365. if (!bboxOverlaps(bbox, entry.bbox)) continue
  366. if (turf.booleanIntersects(polygon, entry.feature)) return entry
  367. }
  368. return null
  369. }
  370. // ------------------------------------------------------------------- pipeline
  371. const allMasks = JSON.parse(readFileSync(resolve(CITY_DIR, 'residential-clipped.geojson'), 'utf8')).features
  372. const masks = allMasks
  373. .filter((mask) => MASK_FILTER === null || mask.properties.ORIG_FID === MASK_FILTER)
  374. .sort((a, b) => a.properties.ORIG_FID - b.properties.ORIG_FID)
  375. if (!masks.length) throw new Error(`No residential mask matched ${MASK_FILTER}`)
  376. const roads = loadLayer('roads-clipped.geojson')
  377. const waters = loadLayer('water-clipped.geojson')
  378. const osmBuildings = loadLayer('osm-buildings-clipped.geojson')
  379. const regularRoofs = loadLayer('regular-estate-roofs.geojson')
  380. const REJECTION_ORDER = [
  381. 'areaBelowMin',
  382. 'areaAboveMax',
  383. 'fillRatioBelowMin',
  384. 'longSideAboveMax',
  385. 'shortSideBelowMin',
  386. 'ringDominatedByVegetation',
  387. 'outsideMask',
  388. 'outsidePrimaryArea',
  389. 'intersectsRoad',
  390. 'intersectsWater',
  391. 'intersectsOsmBuilding',
  392. 'overlapsRegularEstateRoof',
  393. ]
  394. const features = []
  395. const auditPanels = []
  396. const rejectionTotals = Object.fromEntries(REJECTION_ORDER.map((reason) => [reason, 0]))
  397. const perMask = []
  398. let tilesConsidered = 0
  399. let tilesRead = 0
  400. let tilesMissing = 0
  401. /**
  402. * Segment one residential mask. Kept as a function so every window-sized buffer goes out of scope
  403. * before the next mask starts; the largest masks need several gigabytes at z19.
  404. */
  405. function segmentMask(mask) {
  406. const maskId = mask.properties.ORIG_FID
  407. const rings = mask.geometry.type === 'Polygon' ? mask.geometry.coordinates : mask.geometry.coordinates.flat()
  408. const bbox = turf.bbox(mask)
  409. const range = tileRange(bbox, Z)
  410. // The window is the mask bbox snapped out to whole tiles, so the ring test has context off-mask.
  411. const px0 = range.x0 * TILE
  412. const py0 = range.y0 * TILE
  413. const winW = (range.x1 - range.x0 + 1) * TILE
  414. const winH = (range.y1 - range.y0 + 1) * TILE
  415. const pixelCount = winW * winH
  416. const mpp = metresPerPixel((bbox[1] + bbox[3]) / 2)
  417. const considered = (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1)
  418. tilesConsidered += considered
  419. let read = 0
  420. let missing = 0
  421. const rgb = new Uint8Array(pixelCount * 3)
  422. for (let ty = range.y0; ty <= range.y1; ty++) {
  423. for (let tx = range.x0; tx <= range.x1; tx++) {
  424. const tile = readTile(Z, tx, ty)
  425. if (!tile) { missing++; continue }
  426. read++
  427. blitTile(tile.image, rgb, winW, winH, tx * TILE - px0, ty * TILE - py0)
  428. }
  429. }
  430. tilesRead += read
  431. tilesMissing += missing
  432. const brightness = new Uint8Array(pixelCount)
  433. for (let i = 0; i < pixelCount; i++) brightness[i] = (rgb[i * 3] + rgb[i * 3 + 1] + rgb[i * 3 + 2]) / 3
  434. const blur = boxBlur3(brightness, winW, winH)
  435. const value = classify(rgb, blur, winW, winH)
  436. // Candidates only exist inside the mask; off-mask pixels stay as context for the ring test.
  437. const inMask = rasterizePolygon(rings, px0, py0, winW, winH)
  438. for (let i = 0; i < pixelCount; i++) if (value[i] >= 3 && !inMask[i]) value[i] = 0
  439. const eroded = erode(value, winW, winH, THRESHOLDS.erodeRadius)
  440. const { labels, count } = labelComponents(eroded, winW, winH)
  441. const grown = dilateLabels(labels, value, winW, winH, THRESHOLDS.erodeRadius)
  442. // Bucket pixels by component so each component is one contiguous slice of `index`.
  443. const sizes = new Int32Array(count + 2)
  444. for (let i = 0; i < pixelCount; i++) if (grown[i]) sizes[grown[i]]++
  445. const start = new Int32Array(count + 2)
  446. for (let l = 1; l <= count; l++) start[l + 1] = start[l] + sizes[l]
  447. const cursor = start.slice()
  448. const index = new Int32Array(start[count + 1])
  449. for (let i = 0; i < pixelCount; i++) {
  450. const l = grown[i]
  451. if (l) index[cursor[l]++] = i
  452. }
  453. const maskFeatures = []
  454. const maskRejections = Object.fromEntries(REJECTION_ORDER.map((reason) => [reason, 0]))
  455. const rejectedRects = []
  456. const member = new Uint8Array(pixelCount)
  457. const ringFlag = new Uint8Array(pixelCount)
  458. const touched = []
  459. for (let label = 1; label <= count; label++) {
  460. const from = start[label]
  461. const to = start[label + 1]
  462. if (from === to) continue
  463. const points = []
  464. let brightnessSum = 0
  465. let family = 0
  466. for (let i = from; i < to; i++) {
  467. const p = index[i]
  468. member[p] = 1
  469. points.push([(p % winW) + 0.5, ((p / winW) | 0) + 0.5])
  470. brightnessSum += blur[p]
  471. family |= value[p] === 3 ? 1 : 2
  472. }
  473. const areaSqm = points.length * mpp * mpp
  474. let reason = null
  475. if (areaSqm < THRESHOLDS.minAreaSqm) reason = 'areaBelowMin'
  476. else if (areaSqm > THRESHOLDS.maxAreaSqm) reason = 'areaAboveMax'
  477. let rect = null
  478. let fillRatio = 0
  479. let longSideM = 0
  480. let shortSideM = 0
  481. if (!reason) {
  482. rect = minAreaRect(points)
  483. const rectArea = rect ? rect.area : 0
  484. fillRatio = rectArea > 0 ? points.length / rectArea : 0
  485. longSideM = rect ? Math.max(rect.lenU, rect.lenV) * mpp : 0
  486. shortSideM = rect ? Math.min(rect.lenU, rect.lenV) * mpp : 0
  487. if (fillRatio < THRESHOLDS.minFillRatio) reason = 'fillRatioBelowMin'
  488. else if (longSideM > THRESHOLDS.maxLongSideM) reason = 'longSideAboveMax'
  489. else if (shortSideM < THRESHOLDS.minShortSideM) reason = 'shortSideBelowMin'
  490. }
  491. // Ring test: a candidate ringed by vegetation is a field or a courtyard, not a roof.
  492. if (!reason) {
  493. let ringPixels = 0
  494. let vegetation = 0
  495. const R = THRESHOLDS.ringRadiusPx
  496. touched.length = 0
  497. for (let i = from; i < to; i++) {
  498. const p = index[i]
  499. const x = p % winW
  500. const y = (p / winW) | 0
  501. for (let dy = -R; dy <= R; dy++) {
  502. const yy = y + dy
  503. if (yy < 0 || yy >= winH) continue
  504. const row = yy * winW
  505. for (let dx = -R; dx <= R; dx++) {
  506. const xx = x + dx
  507. if (xx < 0 || xx >= winW) continue
  508. const q = row + xx
  509. if (member[q] || ringFlag[q]) continue
  510. ringFlag[q] = 1
  511. touched.push(q)
  512. ringPixels++
  513. if (value[q] === 1) vegetation++
  514. }
  515. }
  516. }
  517. for (const q of touched) ringFlag[q] = 0
  518. if (ringPixels > 0 && vegetation / ringPixels > THRESHOLDS.maxRingVegetation) reason = 'ringDominatedByVegetation'
  519. }
  520. let ring = null
  521. if (!reason) {
  522. const windowCorners = rectCorners(rect)
  523. ring = windowCorners.map(([x, y]) => [pxToLon(px0 + x), pyToLat(py0 + y)])
  524. ring.push(ring[0])
  525. const polygon = turf.polygon([ring])
  526. const cheapInside = windowCorners.every(([x, y]) => {
  527. const xi = Math.round(x - 0.5)
  528. const yi = Math.round(y - 0.5)
  529. return xi >= 0 && yi >= 0 && xi < winW && yi < winH && inMask[yi * winW + xi] === 1
  530. })
  531. const polygonBbox = turf.bbox(polygon)
  532. if (!cheapInside || !turf.booleanWithin(polygon, mask)) reason = 'outsideMask'
  533. else if (!isWithinPrimaryArea(polygon)) reason = 'outsidePrimaryArea'
  534. else if (firstIntersection(polygon, polygonBbox, roads)) reason = 'intersectsRoad'
  535. else if (firstIntersection(polygon, polygonBbox, waters)) reason = 'intersectsWater'
  536. else if (firstIntersection(polygon, polygonBbox, osmBuildings)) reason = 'intersectsOsmBuilding'
  537. else if (firstIntersection(polygon, polygonBbox, regularRoofs)) reason = 'overlapsRegularEstateRoof'
  538. }
  539. if (reason) {
  540. maskRejections[reason]++
  541. rejectionTotals[reason]++
  542. // Only near misses are drawn on the audit: components too small to have a rectangle are
  543. // noise and would bury the accepted outlines.
  544. if (rect) rejectedRects.push(rectCorners(rect).map(([x, y]) => [px0 + x, py0 + y]))
  545. } else {
  546. // Heading of the long side, degrees clockwise from north, normalised to [0, 180).
  547. const width = Math.hypot(ring[1][0] - ring[0][0], ring[1][1] - ring[0][1])
  548. const height = Math.hypot(ring[2][0] - ring[1][0], ring[2][1] - ring[1][1])
  549. const longIsWidth = width >= height
  550. const a = longIsWidth ? ring[0] : ring[1]
  551. const b = longIsWidth ? ring[1] : ring[2]
  552. const east = (b[0] - a[0]) * Math.cos((((a[1] + b[1]) / 2) * Math.PI) / 180)
  553. const north = -(b[1] - a[1])
  554. const headingDeg = ((((Math.atan2(east, north) * 180) / Math.PI) % 180) + 180) % 180
  555. const tileRefs = new Set()
  556. for (let i = from; i < to; i++) {
  557. const p = index[i]
  558. tileRefs.add(`${Z}/${Math.floor((px0 + (p % winW)) / TILE)}/${Math.floor((py0 + ((p / winW) | 0)) / TILE)}`)
  559. }
  560. maskFeatures.push({
  561. type: 'Feature',
  562. properties: {
  563. id: `img-${maskId}-${maskFeatures.length + 1}`,
  564. maskId,
  565. source: 'imagery-segmentation',
  566. areaSqm: round(areaSqm, 1),
  567. longSideM: round(longSideM, 1),
  568. shortSideM: round(shortSideM, 1),
  569. headingDeg: round(headingDeg, 1),
  570. fillRatio: round(fillRatio, 3),
  571. meanBrightness: round(brightnessSum / points.length, 1),
  572. tileRefs: [...tileRefs].sort().map((ref) => ref.split('/').map(Number)),
  573. roofFamily: family === 1 ? 'dark' : family === 2 ? 'light' : 'mixed',
  574. },
  575. geometry: { type: 'Polygon', coordinates: [ring] },
  576. })
  577. }
  578. for (let i = from; i < to; i++) member[index[i]] = 0
  579. }
  580. console.log(`mask ${maskId}: ${count} candidate components, ${maskFeatures.length} accepted (${read} tiles read, ${missing} missing)`)
  581. return {
  582. maskFeatures,
  583. auditPanel: { maskId, bbox, features: maskFeatures, rejected: rejectedRects },
  584. summary: {
  585. maskId,
  586. maskAreaSqm: Math.round(turf.area(mask)),
  587. tileRange: { z: Z, x0: range.x0, x1: range.x1, googleY0: range.y0, googleY1: range.y1 },
  588. tilesConsidered: considered,
  589. tilesRead: read,
  590. tilesMissing: missing,
  591. windowPx: [winW, winH],
  592. metresPerPixel: round(mpp, 4),
  593. candidateComponents: count,
  594. accepted: maskFeatures.length,
  595. rejected: REJECTION_ORDER.reduce((sum, key) => sum + maskRejections[key], 0),
  596. rejections: maskRejections,
  597. },
  598. }
  599. }
  600. for (const mask of masks) {
  601. const { maskFeatures, auditPanel, summary } = segmentMask(mask)
  602. features.push(...maskFeatures)
  603. auditPanels.push(auditPanel)
  604. perMask.push(summary)
  605. }
  606. // --------------------------------------------------------------- assertions
  607. // Re-test every accepted footprint against all six exclusions, independently of the loop above.
  608. const violations = []
  609. for (const feature of features) {
  610. const polygon = turf.feature(feature.geometry)
  611. const polygonBbox = turf.bbox(polygon)
  612. const mask = masks.find((m) => m.properties.ORIG_FID === feature.properties.maskId)
  613. if (!turf.booleanWithin(polygon, mask)) violations.push([feature.properties.id, 'outsideMask'])
  614. if (!isWithinPrimaryArea(polygon)) violations.push([feature.properties.id, 'outsidePrimaryArea'])
  615. for (const [name, layer] of [['road', roads], ['water', waters], ['osmBuilding', osmBuildings], ['regularEstateRoof', regularRoofs]]) {
  616. if (firstIntersection(polygon, polygonBbox, layer)) violations.push([feature.properties.id, `intersects${name[0].toUpperCase()}${name.slice(1)}`])
  617. }
  618. }
  619. console.log(`exclusion assertions on ${features.length} accepted footprints: ${violations.length} violations`)
  620. if (violations.length) throw new Error(`Accepted footprints violate exclusions: ${JSON.stringify(violations.slice(0, 10))}`)
  621. // ------------------------------------------------------------------- outputs
  622. const sortedReasons = Object.entries(rejectionTotals).sort(
  623. (a, b) => b[1] - a[1] || REJECTION_ORDER.indexOf(a[0]) - REJECTION_ORDER.indexOf(b[0])
  624. )
  625. writeFileSync(OUT_GEOJSON, JSON.stringify({ type: 'FeatureCollection', features }, null, 2))
  626. const audit = renderAudit(auditPanels)
  627. writeFileSync(OUT_AUDIT, PNG.sync.write(audit.image, { deflateLevel: 6 }))
  628. writeFileSync(
  629. OUT_REPORT,
  630. JSON.stringify(
  631. {
  632. source: SOURCE,
  633. imageryDir: IMAGERY_DIR,
  634. command: 'node tools/city-massing/segment-imagery-footprints.mjs',
  635. thresholds: THRESHOLDS,
  636. tiles: {
  637. z19: { considered: tilesConsidered, read: tilesRead, missing: tilesMissing },
  638. z18Audit: { read: audit.tilesRead, missing: audit.tilesMissing },
  639. },
  640. tileRefFormat: 'tileRefs entries are [z, x, tmsY]; tmsY = 2**z - 1 - googleY matches the on-disk path',
  641. perMask,
  642. totals: {
  643. accepted: features.length,
  644. candidateComponents: perMask.reduce((sum, m) => sum + m.candidateComponents, 0),
  645. rejected: perMask.reduce((sum, m) => sum + m.rejected, 0),
  646. },
  647. rejectionReasons: Object.fromEntries(sortedReasons),
  648. exclusionAssertions: {
  649. checked: features.length,
  650. violations: violations.length,
  651. tests: ['fullyInsideResidentialMask', 'fullyInsidePrimaryArea', 'noRoadIntersection', 'noWaterIntersection', 'noOsmBuildingIntersection', 'noRegularEstateRoofOverlap'],
  652. },
  653. auditImage: {
  654. path: 'tools/city-massing/city/imagery-footprints-audit.png',
  655. layout: `per-mask z18 crops stacked vertically; accepted footprints 2 px bright yellow, rejected components 1 px red; panels top to bottom: ${auditPanels.map((p) => `mask ${p.maskId}`).join(', ')}`,
  656. size: [audit.image.width, audit.image.height],
  657. },
  658. limitations: [
  659. 'This is imagery segmentation, not survey geometry. Boundaries follow visible roof pixels at about 0.26 m/px.',
  660. 'Two touching buildings with no shadow or brightness edge between them merge into a single footprint.',
  661. 'Very dark roofs on dark ground are missed or merged with the ground, because brightness cannot separate them.',
  662. 'Dark blue roofs can be classified as water, because they are dark, blue-dominant and smooth.',
  663. 'The emitted polygon is a minimum-area rotated rectangle: it does not follow L-shapes, courtyards or roof ridges.',
  664. 'Bare bright ground (construction spoil, concrete yards) can be classified as light roof; the fill, side and ring filters remove most but not all of it.',
  665. 'Positional uncertainty is on the order of one to two pixels, roughly 0.3-0.6 m.',
  666. 'No height, storey count or roof material is derived; only the footprint outline.',
  667. ],
  668. },
  669. null,
  670. 2
  671. )
  672. )
  673. console.log(
  674. JSON.stringify({
  675. command: 'node tools/city-massing/segment-imagery-footprints.mjs',
  676. accepted: features.length,
  677. perMask: perMask.map((m) => [m.maskId, m.accepted]),
  678. rejectionReasons: Object.fromEntries(sortedReasons),
  679. tilesRead,
  680. tilesMissing,
  681. audit: [audit.image.width, audit.image.height],
  682. })
  683. )
  684. // ---------------------------------------------------------------- audit image
  685. function renderAudit(panels) {
  686. const rendered = panels.map(renderPanel)
  687. const width = Math.max(...rendered.map((panel) => panel.width))
  688. const height = rendered.reduce((sum, panel) => sum + panel.height, 0)
  689. const image = new PNG({ width, height })
  690. image.data.fill(255)
  691. let offset = 0
  692. for (const panel of rendered) {
  693. for (let y = 0; y < panel.height; y++) {
  694. const from = y * panel.width * 4
  695. image.data.set(panel.data.subarray(from, from + panel.width * 4), (offset + y) * width * 4)
  696. }
  697. offset += panel.height
  698. }
  699. return {
  700. image,
  701. tilesRead: rendered.reduce((sum, panel) => sum + panel.tilesRead, 0),
  702. tilesMissing: rendered.reduce((sum, panel) => sum + panel.tilesMissing, 0),
  703. }
  704. }
  705. /** One mask panel: z18 imagery backdrop, accepted outlines bright yellow, rejected components red. */
  706. function renderPanel(panel) {
  707. const range = tileRange(panel.bbox, Z_AUDIT)
  708. const x0 = range.x0 * TILE
  709. const y0 = range.y0 * TILE
  710. const cropW = (range.x1 - range.x0 + 1) * TILE
  711. const cropH = (range.y1 - range.y0 + 1) * TILE
  712. const factor = Math.min(1, AUDIT_MAX_SIDE / cropW, AUDIT_MAX_SIDE / cropH)
  713. const width = Math.max(1, Math.round(cropW * factor))
  714. const height = Math.max(1, Math.round(cropH * factor))
  715. const image = new PNG({ width, height })
  716. image.data.fill(255)
  717. const cache = new Map()
  718. let tilesRead = 0
  719. let tilesMissing = 0
  720. for (let py = 0; py < height; py++) {
  721. const cy = Math.min(cropH - 1, Math.floor(py / factor))
  722. for (let px = 0; px < width; px++) {
  723. const cx = Math.min(cropW - 1, Math.floor(px / factor))
  724. const gx = x0 + cx
  725. const gy = y0 + cy
  726. const tx = Math.floor(gx / TILE)
  727. const ty = Math.floor(gy / TILE)
  728. const key = tx * 1e6 + ty
  729. let tile = cache.get(key)
  730. if (tile === undefined) {
  731. tile = readTile(Z_AUDIT, tx, ty)
  732. if (tile) tilesRead++
  733. else tilesMissing++
  734. cache.set(key, tile)
  735. }
  736. const d = (py * width + px) * 4
  737. if (tile) {
  738. const s = ((gy - ty * TILE) * TILE + (gx - tx * TILE)) * 4
  739. image.data[d] = tile.image.data[s]
  740. image.data[d + 1] = tile.image.data[s + 1]
  741. image.data[d + 2] = tile.image.data[s + 2]
  742. }
  743. image.data[d + 3] = 255
  744. }
  745. }
  746. const project = (x, y) => [(x - x0) * factor, (y - y0) * factor]
  747. const stroke = (a, b, color, half) => {
  748. const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
  749. for (let i = 0; i <= steps; i++) {
  750. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps)
  751. const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps)
  752. for (let dy = -half; dy <= half; dy++) {
  753. for (let dx = -half; dx <= half; dx++) {
  754. const xx = x + dx
  755. const yy = y + dy
  756. if (xx < 0 || yy < 0 || xx >= width || yy >= height) continue
  757. const k = (yy * width + xx) * 4
  758. image.data[k] = color[0]
  759. image.data[k + 1] = color[1]
  760. image.data[k + 2] = color[2]
  761. }
  762. }
  763. }
  764. }
  765. // Rectangles and polygon corners are held in z19 window pixels; the backdrop is at Z_AUDIT.
  766. const zoomShift = 2 ** (Z - Z_AUDIT)
  767. for (const corners of panel.rejected) {
  768. const pts = corners.map(([x, y]) => project(x / zoomShift, y / zoomShift))
  769. for (let i = 0; i < 4; i++) stroke(pts[i], pts[(i + 1) % 4], [255, 0, 0], 0)
  770. }
  771. for (const feature of panel.features) {
  772. const pts = feature.geometry.coordinates[0].slice(0, 4).map(([lon, lat]) => project(lonToPx(lon) / zoomShift, latToPy(lat) / zoomShift))
  773. for (let i = 0; i < 4; i++) stroke(pts[i], pts[(i + 1) % 4], [255, 255, 0], 1)
  774. }
  775. return { width, height, data: image.data, tilesRead, tilesMissing }
  776. }