File size: 7,642 Bytes
8240fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ad4e5a
 
 
 
8240fca
 
 
 
 
5ad4e5a
 
 
8240fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ad4e5a
 
 
8240fca
5ad4e5a
 
8240fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// A sample's bands: read from its image file on Hugging Face, decoded and drawn in the browser.
//
//   image file row group (one per sample) -> band column = the original GeoTIFF bytes (LZW; kept for download)
//     -> geotiff.js -> values (S2: uint16 at 10, 20 or 60 m; S1: float32 linear backscatter at 7.5 m; DEM: float32 m)
//     -> a view: one band in grey (2-98% stretch), or a composite of three (S2: one stretch for the three) -> PNG
//   Every band of a sample covers the same 10,680 m square, so bands of different resolutions are combined by
//   sampling each at the composite's pixel positions (nearest).

import { fromArrayBuffer } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
import { compressors, newStats, openFile, parquetRead } from './parquet.js'
import { imageUrl } from './samples.js'

const NOT_BANDS = new Set(['product_id', 'grid_cell', 'product_datetime', 'thumbnail', 'compressed'])

/** The views offered for a dataset: composites first, then every band of its image files. */
export function views(name, bands) {
  const composites = {
    S2L2A: [['True colour (B04, B03, B02)', ['B04', 'B03', 'B02']], ['False colour (B08, B04, B03)', ['B08', 'B04', 'B03']],
      ['SWIR (B12, B8A, B04)', ['B12', 'B8A', 'B04']]],
    S1RTC: [['VV, VH, VV/VH (dB)', ['vv', 'vh', 'ratio']]],
    DEM: [['Elevation (colour)', ['DEM']]],
  }
  composites.S2L1C = composites.S2L2A
  return [...(composites[name] ?? []).map(([label, use]) => ({ id: `rgb:${use.join(',')}`, label, use })),
    ...bands.map(b => ({ id: b, label: b, use: [b] }))]
}

const raw = new Map()                      // "<url>#<row group>/<band>" -> GeoTIFF bytes

/** The band columns of a sample's image file (every column holding a raster). */
export async function bandNames(info, sample) {
  const { metadata } = await openFile(imageUrl(info, sample.part), newStats())
  return metadata.schema.slice(1).map(c => c.name).filter(n => !NOT_BANDS.has(n))
}

/** The GeoTIFF bytes of `bands` of one sample (read once, then kept): {name: Uint8Array}, and the read's stats.
 *  stats.expected: the bytes the missing columns take in the row group (for a progress display), set before reading. */
export async function readBands(info, sample, bands, stats = newStats()) {
  const url = imageUrl(info, sample.part), key = b => `${url}#${sample.parquet_row}/${b}`
  const missing = bands.filter(b => !raw.has(key(b)))
  if (missing.length) {
    const { file, metadata } = await openFile(url, stats)
    let rowStart = 0
    for (let g = 0; g < sample.parquet_row; g++) rowStart += Number(metadata.row_groups[g].num_rows)
    stats.expected = metadata.row_groups[sample.parquet_row].columns
      .filter(c => missing.includes(c.meta_data.path_in_schema[0]))
      .reduce((sum, c) => sum + Number(c.meta_data.total_compressed_size), 0)
    await parquetRead({ file, metadata, compressors, columns: missing, rowStart, rowEnd: rowStart + 1, rowFormat: 'object',
      utf8: false, onComplete(rows) { for (const b of missing) raw.set(key(b), rows[0][b]) } })
  }
  stats.read = performance.now() - stats.t0
  return [Object.fromEntries(bands.map(b => [b, raw.get(key(b))])), stats]
}

/** Values of one GeoTIFF: {width, height, values (typed array), noData}. */
async function decode(bytes) {
  const tiff = await fromArrayBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
  const image = await tiff.getImage(), [values] = await image.readRasters()
  return { width: image.getWidth(), height: image.getHeight(), values, noData: image.getGDALNoData() }
}

/** 2nd and 98th percentile of the finite values (and not noData), from a sample of 100,000 pixels. */
function stretch(values, noData) {
  const step = Math.max(1, Math.floor(values.length / 100000)), picked = []
  for (let i = 0; i < values.length; i += step) {
    const v = values[i]
    if (Number.isFinite(v) && v !== noData) picked.push(v)
  }
  if (!picked.length) return [0, 1]
  picked.sort((a, b) => a - b)
  const lo = picked[Math.floor(picked.length * 0.02)], hi = picked[Math.floor(picked.length * 0.98)]
  return hi > lo ? [lo, hi] : [lo, lo + 1]
}

const dB = v => (v > 0 ? 10 * Math.log10(v) : NaN)
const TERRAIN = [[0, [26, 102, 51]], [0.3, [127, 176, 82]], [0.55, [222, 207, 140]], [0.8, [153, 102, 51]], [1, [250, 250, 250]]]

function terrain(t) {
  for (let k = 1; k < TERRAIN.length; k++) {
    const [t1, c1] = TERRAIN[k], [t0, c0] = TERRAIN[k - 1]
    if (t <= t1) { const f = (t - t0) / (t1 - t0); return c0.map((c, i) => c + f * (c1[i] - c)) }
  }
  return TERRAIN.at(-1)[1]
}

/** One view of a sample as a PNG object URL: {src, size, values (single band: for the cursor), stats}.
 *  stats (passed in to follow progress): bytes as they arrive, expected, then phase 'Decoding'. */
export async function render(info, sample, view, stats = newStats()) {
  const needed = [...new Set(view.use.flatMap(b => (b === 'ratio' ? ['vv', 'vh'] : [b])))]
  const [bytes] = await readBands(info, sample, needed, stats)
  stats.phase = 'Decoding'
  const bands = Object.fromEntries(await Promise.all(needed.map(async b => [b, await decode(bytes[b])])))
  const size = Math.max(...needed.map(b => bands[b].width))
  const s1 = 'vv' in bands, at = (band, x, y) => {             // value at composite pixel (x, y), nearest
    const k = band.width / size
    return band.values[Math.floor(y * k) * band.width + Math.floor(x * k)]
  }
  const channel = name => {
    if (name === 'ratio') {
      const vv = bands.vv, vh = bands.vh, out = new Float32Array(size * size)
      for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) out[y * size + x] = dB(at(vv, x, y)) - dB(at(vh, x, y))
      return { width: size, values: out, noData: null }
    }
    const b = bands[name]
    if (!s1) return b
    const out = new Float32Array(b.values.length)                       // S1: linear backscatter -> dB
    for (let i = 0; i < out.length; i++) out[i] = dB(b.values[i])
    return { width: b.width, values: out, noData: null }
  }
  const chans = view.use.map(channel), own = chans.map(c => stretch(c.values, c.noData))
  // S2 composites: one stretch for the three bands (reflectances on one scale), so colours keep their balance;
  // S1: each channel its own (VV, VH and their ratio are different quantities)
  const joint = !s1 && chans.length === 3 ? [Math.min(...own.map(r => r[0])), Math.max(...own.map(r => r[1]))] : null
  const ranges = joint ? own.map(() => joint) : own
  const canvas = new OffscreenCanvas(size, size), ctx = canvas.getContext('2d'), img = ctx.createImageData(size, size)
  const elevation = view.id === 'rgb:DEM'
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const o = (y * size + x) * 4
      const t = chans.map((c, k) => {
        const v = at(c, x, y), [lo, hi] = ranges[k]
        return Number.isFinite(v) && v !== c.noData ? Math.min(Math.max((v - lo) / (hi - lo), 0), 1) : NaN
      })
      if (t.some(Number.isNaN)) { img.data[o + 3] = 0; continue }
      const rgb = elevation ? terrain(t[0]) : chans.length === 1 ? [t[0] * 255, t[0] * 255, t[0] * 255] : t.map(v => v * 255)
      img.data[o] = rgb[0]; img.data[o + 1] = rgb[1]; img.data[o + 2] = rgb[2]; img.data[o + 3] = 255
    }
  }
  ctx.putImageData(img, 0, 0)
  const src = URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
  const single = view.use.length === 1 && !elevation ? chans[0] : elevation ? bands.DEM : null
  stats.read = performance.now() - stats.t0
  return { src, size, values: single && { width: single.width, values: single.values, unit: s1 ? 'dB' : elevation ? 'm' : '' }, stats }
}