draw-model-coverage.mjs 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. // Draw the generated city footprints over the local satellite atlas, coloured by evidence source,
  2. // so a human can see at a glance whether models sit on the imagery instead of on roads or fields.
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { PNG } from 'pngjs'
  6. import { candidateFootprint } from './oldtown-geometry.mjs'
  7. const cityDir = resolve('tools/city-massing/city')
  8. const atlas = JSON.parse(readFileSync(resolve(cityDir, 'city-inventory.json')))
  9. const image = PNG.sync.read(readFileSync(resolve(cityDir, 'city-satellite.png')))
  10. const seated = JSON.parse(readFileSync(resolve(cityDir, 'oldtown/seated-plan.json')))
  11. const scale = 2 ** atlas.imagery.zoom * 256
  12. function project([lon, lat]) {
  13. const x = ((lon + 180) / 360) * scale - atlas.imagery.westTile * 256
  14. const y = ((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * scale - atlas.imagery.googleY0 * 256
  15. return [x, y]
  16. }
  17. const COLORS = {
  18. 'imagery-roof-segmentation': [255, 225, 0],
  19. 'osm-building-footprint': [0, 235, 255],
  20. }
  21. function draw(a, b, color, width) {
  22. const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])))
  23. for (let i = 0; i <= steps; i++) {
  24. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / steps)
  25. const y = Math.round(a[1] + ((b[1] - a[1]) * i) / steps)
  26. for (let dy = -width; dy <= width; dy++)
  27. for (let dx = -width; dx <= width; dx++) {
  28. const px = x + dx, py = y + dy
  29. if (px < 0 || py < 0 || px >= image.width || py >= image.height) continue
  30. const k = (py * image.width + px) * 4
  31. image.data[k] = color[0]
  32. image.data[k + 1] = color[1]
  33. image.data[k + 2] = color[2]
  34. image.data[k + 3] = 255
  35. }
  36. }
  37. }
  38. let drawn = 0
  39. const counts = {}
  40. for (const building of seated.buildings) {
  41. const lon = building.lon ?? building.center?.lon
  42. const lat = building.lat ?? building.center?.lat
  43. if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue
  44. const color = COLORS[building.placementSource] || [255, 0, 0]
  45. counts[building.placementSource] = (counts[building.placementSource] || 0) + 1
  46. const ring = candidateFootprint({
  47. lon, lat, width: building.width, depth: building.depth, heading: building.heading,
  48. }).geometry.coordinates[0].map(project)
  49. for (let i = 0; i < ring.length - 1; i++) draw(ring[i], ring[i + 1], color, 0)
  50. drawn++
  51. }
  52. const out = resolve(cityDir, 'city-model-coverage.png')
  53. writeFileSync(out, PNG.sync.write(image))
  54. // A 1:1 crop of the city core, where the user reported missing and misplaced buildings.
  55. const cropSize = 1400
  56. const [cx, cy] = project([110.3965, 28.4570])
  57. const crop = new PNG({ width: cropSize, height: cropSize })
  58. PNG.bitblt(image, crop, Math.round(cx - cropSize / 2), Math.round(cy - cropSize / 2), cropSize, cropSize, 0, 0)
  59. const cropOut = resolve(cityDir, 'city-model-coverage-core.png')
  60. writeFileSync(cropOut, PNG.sync.write(crop))
  61. console.log(JSON.stringify({
  62. drawn,
  63. bySource: counts,
  64. full: { path: out, size: [image.width, image.height] },
  65. coreCrop: { path: cropOut, size: [cropSize, cropSize] },
  66. legend: 'yellow = satellite roof segmentation, cyan = mapped OSM footprint',
  67. }))