verify-imagery-footprints.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. // Full-resolution visual check of imagery-derived footprints: crops z19 imagery 1:1 and outlines
  2. // the accepted footprints so a human can judge whether they really sit on roofs.
  3. import { readFileSync, writeFileSync, existsSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { PNG } from 'pngjs'
  6. import jpeg from 'jpeg-js'
  7. import * as turf from '@turf/turf'
  8. const DIR = process.env.GIS_DIR || 'D:/GIS/GIS'
  9. const cityDir = resolve('tools/city-massing/city')
  10. const collections = [
  11. { path: resolve(cityDir, 'imagery-footprints.geojson'), color: [255, 235, 0], label: 'imagery' },
  12. { path: resolve(cityDir, 'osm-buildings-clipped.geojson'), color: [0, 220, 255], label: 'osm' },
  13. { path: resolve(cityDir, 'regular-estate-roofs.geojson'), color: [255, 140, 0], label: 'estate' },
  14. { path: resolve(cityDir, 'roads-clipped.geojson'), color: [255, 0, 255], label: 'roads' },
  15. ]
  16. const layers = collections
  17. .filter((entry) => existsSync(entry.path))
  18. .map((entry) => ({ ...entry, features: JSON.parse(readFileSync(entry.path)).features }))
  19. const ZOOM = 19
  20. const TILE = 256
  21. const CROP = 4 // tiles per side
  22. const panels = []
  23. const centres = JSON.parse(process.argv[2] || '[]')
  24. function project([lon, lat]) {
  25. const scale = 2 ** ZOOM
  26. return [
  27. ((lon + 180) / 360) * scale * TILE,
  28. ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * scale * TILE,
  29. ]
  30. }
  31. for (const centre of centres) {
  32. const [cx, cy] = project(centre)
  33. const x0 = Math.floor(cx / TILE) - CROP / 2 + 1
  34. const y0 = Math.floor(cy / TILE) - CROP / 2 + 1
  35. const size = CROP * TILE
  36. const image = new PNG({ width: size, height: size })
  37. image.data.fill(30)
  38. for (let ty = 0; ty < CROP; ty++) {
  39. for (let tx = 0; tx < CROP; tx++) {
  40. const gx = x0 + tx, gy = y0 + ty
  41. const file = `${DIR}/yuanlingPic3/${ZOOM}/${gx}/${2 ** ZOOM - 1 - gy}.png`
  42. if (!existsSync(file)) continue
  43. const bytes = readFileSync(file)
  44. const decoded = bytes[0] === 0xff ? jpeg.decode(bytes) : PNG.sync.read(bytes)
  45. PNG.bitblt(decoded, image, 0, 0, TILE, TILE, tx * TILE, ty * TILE)
  46. }
  47. }
  48. const originX = x0 * TILE, originY = y0 * TILE
  49. function draw(a, b, color, width) {
  50. const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
  51. for (let i = 0; i <= steps; i++) {
  52. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps) - originX
  53. const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps) - originY
  54. for (let dy = -width; dy <= width; dy++)
  55. for (let dx = -width; dx <= width; dx++) {
  56. const px = x + dx, py = y + dy
  57. if (px < 0 || py < 0 || px >= size || py >= size) continue
  58. const k = (py * size + px) * 4
  59. image.data[k] = color[0]
  60. image.data[k + 1] = color[1]
  61. image.data[k + 2] = color[2]
  62. image.data[k + 3] = 255
  63. }
  64. }
  65. }
  66. const window = turf.bboxPolygon([
  67. ...turf.bbox(turf.point(centre)).slice(0, 2),
  68. centre[0] + 0.004,
  69. centre[1] + 0.004,
  70. ])
  71. let drawn = 0
  72. for (const layer of layers) {
  73. for (const feature of layer.features) {
  74. const type = feature.geometry.type
  75. const rings = type === 'Polygon' ? [feature.geometry.coordinates[0]]
  76. : type === 'MultiPolygon' ? feature.geometry.coordinates.map((poly) => poly[0])
  77. : type === 'LineString' ? [feature.geometry.coordinates]
  78. : type === 'MultiLineString' ? feature.geometry.coordinates
  79. : []
  80. if (!rings.length) continue
  81. if (!turf.booleanIntersects(feature, window)) continue
  82. for (const ring of rings)
  83. for (let i = 0; i < ring.length - 1; i++) draw(project(ring[i]), project(ring[i + 1]), layer.color, 1)
  84. drawn++
  85. }
  86. }
  87. panels.push({ image, drawn, centre })
  88. }
  89. const totalWidth = Math.max(...panels.map((panel) => panel.image.width))
  90. const totalHeight = panels.reduce((sum, panel) => sum + panel.image.height, 0)
  91. const sheet = new PNG({ width: totalWidth, height: totalHeight })
  92. sheet.data.fill(20)
  93. let offset = 0
  94. for (const panel of panels) {
  95. PNG.bitblt(panel.image, sheet, 0, 0, panel.image.width, panel.image.height, 0, offset)
  96. offset += panel.image.height
  97. }
  98. const out = resolve(cityDir, 'imagery-footprints-verify.png')
  99. writeFileSync(out, PNG.sync.write(sheet))
  100. console.log(JSON.stringify({
  101. output: out,
  102. panels: panels.map((panel) => ({ centre: panel.centre, outlinesDrawn: panel.drawn })),
  103. legend: 'yellow = imagery footprints, cyan = mapped OSM footprints',
  104. }))