verify-cad-buildings.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. // Validate the CAD building layer against the local imagery and report what the survey contains.
  2. // Writes an overlay so the alignment claim can be checked by eye, not just by counts.
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { PNG } from 'pngjs'
  6. import * as turf from '@turf/turf'
  7. import { PRIMARY_AREA_BOUNDS, isWithinPrimaryArea } from './primary-area.mjs'
  8. const cityDir = resolve('tools/city-massing/city')
  9. const buildings = JSON.parse(readFileSync(resolve(cityDir, 'cad-buildings.geojson'), 'utf8')).features
  10. const labelsPath = resolve(cityDir, 'cad-building-labels.geojson')
  11. let labels = []
  12. try {
  13. labels = JSON.parse(readFileSync(labelsPath, 'utf8')).features
  14. } catch {
  15. labels = []
  16. }
  17. const areas = []
  18. let outside = 0
  19. let degenerate = 0
  20. let vertexHistogram = {}
  21. for (const feature of buildings) {
  22. const ring = feature.geometry.coordinates[0]
  23. vertexHistogram[ring.length] = (vertexHistogram[ring.length] || 0) + 1
  24. const area = turf.area(feature)
  25. if (!(area > 0)) {
  26. degenerate++
  27. continue
  28. }
  29. areas.push(area)
  30. if (!isWithinPrimaryArea(feature)) outside++
  31. }
  32. areas.sort((a, b) => a - b)
  33. const pick = (q) => Math.round(areas[Math.floor(q * (areas.length - 1))] * 10) / 10
  34. // Label text: survey annotations often carry storeys and structure (e.g. "砖混3").
  35. const counts = {}
  36. let withStructure = 0
  37. let withStoreys = 0
  38. for (const label of labels) {
  39. const text = (label.properties.text || '').trim()
  40. if (!text) continue
  41. counts[text] = (counts[text] || 0) + 1
  42. if (/砖混|砖木|框架|土木|钢/.test(text)) withStructure++
  43. if (/\d/.test(text)) withStoreys++
  44. }
  45. const topLabels = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 25)
  46. // --- overlay on the city atlas -------------------------------------------------------------
  47. const atlas = JSON.parse(readFileSync(resolve(cityDir, 'city-inventory.json')))
  48. const image = PNG.sync.read(readFileSync(resolve(cityDir, 'city-satellite.png')))
  49. const scale = 2 ** atlas.imagery.zoom * 256
  50. const project = ([lon, lat]) => [
  51. ((lon + 180) / 360) * scale - atlas.imagery.westTile * 256,
  52. ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * scale - atlas.imagery.googleY0 * 256,
  53. ]
  54. function draw(a, b, color, width) {
  55. const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
  56. for (let i = 0; i <= steps; i++) {
  57. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps)
  58. const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps)
  59. for (let dy = -width; dy <= width; dy++)
  60. for (let dx = -width; dx <= width; dx++) {
  61. const px = x + dx, py = y + dy
  62. if (px < 0 || py < 0 || px >= image.width || py >= image.height) continue
  63. const k = (py * image.width + px) * 4
  64. image.data[k] = color[0]
  65. image.data[k + 1] = color[1]
  66. image.data[k + 2] = color[2]
  67. image.data[k + 3] = 255
  68. }
  69. }
  70. }
  71. let drawn = 0
  72. for (const feature of buildings) {
  73. const rings = feature.geometry.type === 'Polygon' ? feature.geometry.coordinates : []
  74. for (const ring of rings) {
  75. const points = ring.map(project)
  76. for (let i = 0; i < points.length - 1; i++) draw(points[i], points[i + 1], [255, 60, 200], 0)
  77. }
  78. drawn++
  79. }
  80. writeFileSync(resolve(cityDir, 'cad-buildings-overlay.png'), PNG.sync.write(image))
  81. // 1:1 z19 crops are the only way to judge whether a footprint really sits on its roof.
  82. import { readFileSync as read19, existsSync as exists19 } from 'node:fs'
  83. import jpeg19 from 'jpeg-js'
  84. const Z19 = 19, TILE19 = 256, CROP_TILES = 4
  85. function project19([lon, lat]) {
  86. const s = 2 ** Z19
  87. return [((lon + 180) / 360) * s * TILE19, ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * s * TILE19]
  88. }
  89. function crop19(centre, outName) {
  90. const [cx19, cy19] = project19(centre)
  91. const x0 = Math.floor(cx19 / TILE19) - CROP_TILES / 2 + 1
  92. const y0 = Math.floor(cy19 / TILE19) - CROP_TILES / 2 + 1
  93. const size = CROP_TILES * TILE19
  94. const canvas = new PNG({ width: size, height: size })
  95. canvas.data.fill(30)
  96. const dir = process.env.GIS_DIR || 'D:/GIS/GIS'
  97. for (let ty = 0; ty < CROP_TILES; ty++) {
  98. for (let tx = 0; tx < CROP_TILES; tx++) {
  99. const gx = x0 + tx, gy = y0 + ty
  100. const file = `${dir}/yuanlingPic3/${Z19}/${gx}/${2 ** Z19 - 1 - gy}.png`
  101. if (!exists19(file)) continue
  102. const bytes = read19(file)
  103. const decoded = bytes[0] === 0xff ? jpeg19.decode(bytes) : PNG.sync.read(bytes)
  104. PNG.bitblt(decoded, canvas, 0, 0, TILE19, TILE19, tx * TILE19, ty * TILE19)
  105. }
  106. }
  107. const originX = x0 * TILE19, originY = y0 * TILE19
  108. let n = 0
  109. for (const feature of buildings) {
  110. for (const ring of feature.geometry.type === 'Polygon' ? feature.geometry.coordinates : []) {
  111. const pts = ring.map((p) => project19(p))
  112. if (!pts.some(([px, py]) => px > originX - 256 && px < originX + size + 256 && py > originY - 256 && py < originY + size + 256)) continue
  113. n++
  114. for (let i = 0; i < pts.length - 1; i++) {
  115. const a = [pts[i][0] - originX, pts[i][1] - originY]
  116. const b = [pts[i + 1][0] - originX, pts[i + 1][1] - originY]
  117. const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
  118. for (let k = 0; k <= steps; k++) {
  119. const x = Math.round(a[0] + ((b[0] - a[0]) * k) / steps)
  120. const y = Math.round(a[1] + ((b[1] - a[1]) * k) / steps)
  121. if (x < 0 || y < 0 || x >= size || y >= size) continue
  122. const off = (y * size + x) * 4
  123. canvas.data[off] = 255; canvas.data[off + 1] = 40; canvas.data[off + 2] = 200; canvas.data[off + 3] = 255
  124. }
  125. }
  126. }
  127. }
  128. writeFileSync(resolve(cityDir, outName), PNG.sync.write(canvas))
  129. return { out: outName, outlines: n }
  130. }
  131. const crops = [crop19([110.3965, 28.4575], 'cad-buildings-z19-core.png')]
  132. const CROP = 1400
  133. const [cx, cy] = project([110.3965, 28.4570])
  134. const crop = new PNG({ width: CROP, height: CROP })
  135. PNG.bitblt(image, crop, Math.round(cx - CROP / 2), Math.round(cy - CROP / 2), CROP, CROP, 0, 0)
  136. writeFileSync(resolve(cityDir, 'cad-buildings-core.png'), PNG.sync.write(crop))
  137. console.log(JSON.stringify({
  138. cadBuildings: buildings.length,
  139. withValidArea: areas.length,
  140. degenerate,
  141. outsidePrimaryArea: outside,
  142. areaSqm: { min: pick(0), p25: pick(0.25), median: pick(0.5), p75: pick(0.75), p95: pick(0.95), max: pick(1) },
  143. under15Sqm: areas.filter((a) => a < 15).length,
  144. over2000Sqm: areas.filter((a) => a > 2000).length,
  145. vertexHistogram,
  146. labels: { total: labels.length, withStructureText: withStructure, withDigits: withStoreys, top: topLabels },
  147. oneToOneCrops: crops,
  148. overlays: ['cad-buildings-overlay.png', 'cad-buildings-core.png'],
  149. primaryAreaBounds: [...PRIMARY_AREA_BOUNDS],
  150. }, null, 1))