File size: 20,159 Bytes
8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 4cb7bec 8a28d42 | 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 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | // AlphaEarth embeddings (tge-labs/aef on source.coop): a cell's window of its yearly COG tiles, shown as a PCA of the
// 64 bands (fast: a coarse PCA first, then the high-res image from a few bands), or one band.
//
// footprint row (tile_row, tile_rows: lists, one entry per tile under the cell's 10,680 m window, its own first)
// -> footprints/AEF-tiles.parquet, each tile's rows (page index) -> the year's COGs
// each tile gives the part of the window it covers; the parts are joined (a zone's tiles share one 81,920 m grid,
// a whole number of pixels at every level, so they fit exactly); 24.6% of cells need more than one tile
// PCA, in two steps (each source.coop request waits ~0.7 s and few run at once, so requests set the time):
// 1. preview: each tile's whole 160 m level, all 64 bands in one request (~10 MB; the level's tiles are stored
// together) -> the window (67 px) -> PCA (three leading components) -> shown at once (onPreview)
// the level is kept: other cells of the same tile and year skip this read
// 2. high-res: the 8 bands that best predict the three components (forward selection, least squares on the
// 160 m pixels; R² 0.95-0.98 against the full 64-band PCA, measured at 40 m for three places) -> those bands at
// 10 m (geotiff.js, only the tiles under the window) -> the fitted mapping -> 1,068 px
// geotiff.js reads in 1 MB blocks: the COG's headers (one per level) in one request, a band's adjacent tiles in one
// one band: that band at 10 m, grey, 2-98% stretch; downloads: the joined window's bands at 10 m (download.js)
// The COGs are stored south-up (row 0 at the tile's southern edge): the origin and the row direction come from
// each file's own transform, and rows are flipped so north is up.
// Pixels no tile covers (a zone edge, the coast) and no-data (-128) are transparent. PCA colours are the cell's
// own: the same colour means similar embeddings within a cell, not across cells.
import { fromCustomClient, getDecoder } from 'https://cdn.jsdelivr.net/npm/geotiff@2.1.3/+esm'
import proj4 from 'https://cdn.jsdelivr.net/npm/proj4@2.12.1/+esm'
import { compressors, newStats, openFile, parquetRead } from './parquet.js'
import './zstd.js'
const HALF = 5340, NODATA = -128, FIT = 50_000, OVERVIEW = 4, HIGH = 0, CHOSEN = 8 // levels: 4 = 160 m, 0 = 10 m
/** The views offered: the PCA (default), then single bands. */
export function aefViews() {
return [{ id: 'pca', label: 'PCA proxy of 64 bands, 10 m' }, // the 160 m PCA, predicted from 8 bands
...Array.from({ length: 64 }, (_, b) => ({ id: `band:${b}`, label: `Band A${String(b).padStart(2, '0')}, 10 m` }))]
}
/** A geotiff.js client that counts what it reads into `client.stats`: the read under way, set again by openCog. */
function countingClient(url, stats) {
const client = {
stats,
async request({ headers, signal } = {}) {
const r = await fetch(url, { headers, signal }), data = await r.arrayBuffer()
client.stats.bytes += data.byteLength; client.stats.requests += 1
return { ok: r.ok, status: r.status, getHeader: name => r.headers.get(name), getData: async () => data }
},
}
return client
}
/** One tile's rows (one per year): {year, epsg, west, north, path}. */
async function tileRows(info, start, count, stats) {
const url = new URL(`footprints/${info.tiles}`, location.href).href.split('?')[0] + location.search
const { file, metadata } = await openFile(url, stats)
let rows = []
await parquetRead({ file, metadata, compressors, useOffsetIndex: true, rowStart: start, rowEnd: start + count,
rowFormat: 'object', onComplete(r) { rows = r } })
return rows
}
const listOf = v => (Array.isArray(v) || ArrayBuffer.isView(v) ? [...v].map(Number) : [Number(v)])
/** The cell in its UTM zone, and its tiles' files in the sample's year: {epsg, cx, cy, corners, urls}. */
export async function aefCell(info, sample, stats = newStats()) {
const starts = listOf(sample.tile_row), counts = listOf(sample.tile_rows)
const rows = await Promise.all(starts.map((s, k) => tileRows(info, s, counts[k], stats)))
const tiles = rows.map(r => r.find(x => Number(x.year) === sample.year)).filter(Boolean)
if (!tiles.length) throw new Error(`no AlphaEarth tile for ${sample.year}`)
const epsg = Number(tiles[0].epsg)
const utm = proj4(`+proj=utm +zone=${epsg % 100}${epsg > 32700 ? ' +south' : ''} +datum=WGS84 +units=m +no_defs`)
const [cx, cy] = utm.forward([sample.centre_lon, sample.centre_lat])
const corners = [[cx - HALF, cy + HALF], [cx + HALF, cy + HALF], [cx + HALF, cy - HALF], [cx - HALF, cy - HALF]]
.map(q => utm.inverse(q))
return { epsg, cx, cy, corners, urls: tiles.map(x => `${info.files}${x.path}`) }
}
const opened = new Map() // COG url -> {tiff, client, left, top, dy}, most recently used last
const KEEP = 4 // COGs kept open; older ones are dropped with their block cache
/** A COG, opened once (its headers in one 1 MB block), with its origin and row direction; its reads are counted
* into `stats` from now on. At most KEEP stay open, each caching at most 16 blocks (16 MB). */
async function openCog(url, stats) {
if (opened.has(url)) { const c = opened.get(url); opened.delete(url); opened.set(url, c) }
else {
opened.set(url, (async () => {
const client = countingClient(url, stats)
const tiff = await fromCustomClient(client, { blockSize: 2 ** 20, cacheSize: 16 })
const [, , , left, , dy, , top] = (await tiff.getImage(0)).fileDirectory.ModelTransformation // dy > 0: south-up
return { url, tiff, client, left, top, dy }
})().catch(e => { opened.delete(url); throw e }))
while (opened.size > KEEP) opened.delete(opened.keys().next().value)
}
const cog = await opened.get(url)
cog.client.stats = stats
return cog
}
/** Every tile's part of the window, joined: one Int8Array per band (NODATA where no tile covers). */
function join(parts, size) {
return parts[0].map((first, b) => {
const out = Int8Array.from(first)
for (const part of parts.slice(1)) {
const band = part[b]
for (let i = 0; i < out.length; i++) if (out[i] === NODATA) out[i] = band[i]
}
return out
})
}
/** Bands of the cell's window at one level, read from every tile and joined: {bands, size, west, north, res}. */
export async function aefWindow(info, sample, level, samples, stats = newStats()) {
const cell = await aefCell(info, sample, stats), cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
const k = 2 ** level, size = Math.round(2 * HALF / (10 * k)), res = 10 * k
const parts = await Promise.all(cogs.map(async c => {
const image = await c.tiff.getImage(level), w = windowAt(c.left, c.top, c.dy, level, cell.cx, cell.cy)
return readWindow(image, w, samples, stats, c.dy > 0)
}))
const w = windowAt(cogs[0].left, cogs[0].top, cogs[0].dy, level, cell.cx, cell.cy)
return { bands: join(parts.filter(Boolean), size), size, res, west: w.west, north: w.north, cell }
}
/** One view of the cell's window in the sample's year: {src, size, values (one band: for the cursor), corners, stats}.
* For the PCA, onPreview(view) gets the 160 m image as soon as it is drawn. */
export async function renderAef(info, sample, viewId, stats = newStats(), onPreview = () => {}) {
const cell = await aefCell(info, sample, stats)
const done = async (rgba, size, values = null) => {
const src = await png(rgba, size)
stats.read = performance.now() - stats.t0
return { src, size, values, corners: cell.corners, stats }
}
if (viewId.startsWith('band:')) {
const window = await aefWindow(info, sample, 0, [Number(viewId.split(':')[1])], stats), { bands: [band], size } = window
const values = { width: size, values: Float32Array.from(band, v => (v === NODATA ? NaN : v)), unit: '' }
return { ...(await done(greyRgba(band, size), size, values)), window } // window: for the band's GeoTIFF
}
stats.phase = 'Overview, 160 m'
const cogs = await Promise.all(cell.urls.map(u => openCog(u, stats)))
const size8 = Math.round(2 * HALF / (10 * 2 ** OVERVIEW))
const coarse = join(await Promise.all(cogs.map(async c => {
const overview = await overviewOf(c, stats), w = windowAt(c.left, c.top, c.dy, OVERVIEW, cell.cx, cell.cy)
return overview.bands.map(band => cut(band, overview.width, overview.height, w, c.dy > 0))
})), size8)
const pca = fitPca(coarse, size8)
if (!pca) return done(new Uint8ClampedArray(size8 * size8 * 4), size8)
onPreview({ ...(await done(rgbOf(pca.scores, pca.valid, size8), size8)), label: 'PCA of 64 bands, 160 m' })
stats.phase = `Loading high-res: ${CHOSEN} bands at 10 m`
const { chosen, weights } = selectBands(coarse, pca, CHOSEN)
const { bands: fine, size } = await aefWindow(info, sample, HIGH, chosen, stats)
const n = size * size, valid = [], scores = [0, 1, 2].map(() => new Float32Array(n))
for (let i = 0; i < n; i++) {
if (fine[0][i] === NODATA) continue
valid.push(i)
for (let k = 0; k < 3; k++) {
let s = weights[chosen.length][k] // intercept
for (let j = 0; j < chosen.length; j++) s += fine[j][i] * weights[j][k]
scores[k][i] = s
}
}
return done(rgbOf(scores, valid, size), size)
}
/** The cell's window on one level: its size, its position in that level's pixels (rows counted as stored), and its
* west and north edges in metres. */
function windowAt(left, top, dy, level, cx, cy) {
const k = 2 ** level, res = 10 * k, size = Math.round(2 * HALF / res)
const x0 = Math.round((cx - HALF - left) / res)
const y0 = Math.round(Math.min((cy + HALF - top) / (dy * k), (cy - HALF - top) / (dy * k)))
const yEdges = [top + y0 * dy * k, top + (y0 + size) * dy * k]
return { level, size, x0, y0, west: left + x0 * res, north: Math.max(...yEdges) }
}
const overviews = new Map() // COG url -> its 160 m level, all bands (six kept: ~16 MB each)
/** The preview level of a COG (160 m), every band, from one request over the level's tiles (stored together), kept. */
function overviewOf({ url, tiff }, stats) {
if (!overviews.has(url)) {
overviews.set(url, (async () => {
const image = await tiff.getImage(OVERVIEW), fd = image.fileDirectory, offsets = fd.TileOffsets, counts = fd.TileByteCounts
const start = Math.min(...offsets), end = Math.max(...offsets.map((o, i) => o + counts[i]))
stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + (end - start)
const r = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` } })
if (r.status !== 206) throw new Error(`AlphaEarth overview: HTTP ${r.status}`)
const data = await r.arrayBuffer()
stats.bytes += data.byteLength; stats.requests += 1
const decoder = await getDecoder(fd), W = image.getWidth(), H = image.getHeight(), T = fd.TileWidth
const across = Math.ceil(W / T), down = Math.ceil(H / fd.TileLength), per = across * down
const bands = []
for (let b = 0; b < image.getSamplesPerPixel(); b++) {
const band = new Int8Array(W * H)
for (let r = 0; r < down; r++) {
for (let q = 0; q < across; q++) {
const t = b * per + r * across + q
const raw = new Int8Array(await decoder.decode(fd, data.slice(offsets[t] - start, offsets[t] - start + counts[t])))
for (let y = 0; y < T && r * T + y < H; y++) {
const w = Math.min(T, W - q * T)
band.set(raw.subarray(y * T, y * T + w), (r * T + y) * W + q * T)
}
}
}
bands.push(band)
}
if (overviews.size > 6) overviews.delete(overviews.keys().next().value)
return { width: W, height: H, bands }
})().catch(e => { overviews.delete(url); throw e }))
}
return overviews.get(url)
}
/** The window's pixels of some bands in one COG, read through geotiff.js (only the tiles under it), north up;
* null when the COG holds none of the window. */
async function readWindow(image, w, samples, stats, flip) {
const W = image.getWidth(), H = image.getHeight()
const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
if (box[2] <= box[0] || box[3] <= box[1]) return null // this tile holds none of the window
stats.expected = Math.max(stats.expected ?? 0, stats.bytes) + expectedBytes(image, box, samples)
const parts = await image.readRasters({ window: box, samples, interleave: false })
return parts.map(part => place(part, box, w, flip))
}
/** Compressed bytes of the tiles under the window, for the bands read (planar COG: tiles band by band). */
function expectedBytes(image, [a, b, c, d], samples) {
const fd = image.fileDirectory, T = fd.TileWidth, across = Math.ceil(image.getWidth() / T)
const per = across * Math.ceil(image.getHeight() / fd.TileLength)
let sum = 0
for (const s of samples) {
for (let r = Math.floor(b / T); r <= Math.floor((d - 1) / T); r++) {
for (let q = Math.floor(a / T); q <= Math.floor((c - 1) / T); q++) sum += fd.TileByteCounts[s * per + r * across + q]
}
}
return sum
}
/** A read box (part of the window inside the image) placed into the full window, north up, NODATA elsewhere. */
function place(part, [a, b, c, d], w, flip) {
const out = new Int8Array(w.size * w.size).fill(NODATA), width = c - a
for (let y = b; y < d; y++) {
const row = flip ? w.size - 1 - (y - w.y0) : y - w.y0
out.set(part.subarray((y - b) * width, (y - b + 1) * width), row * w.size + (a - w.x0))
}
return out
}
/** The window cut from a whole level held in memory. */
function cut(band, W, H, w, flip) {
const box = [Math.max(w.x0, 0), Math.max(w.y0, 0), Math.min(w.x0 + w.size, W), Math.min(w.y0 + w.size, H)]
const width = box[2] - box[0], part = new Int8Array(Math.max(width, 0) * Math.max(box[3] - box[1], 0))
for (let y = box[1]; y < box[3]; y++) part.set(band.subarray(y * W + box[0], y * W + box[2]), (y - box[1]) * width)
return place(part, box, w, flip)
}
/** PCA of the window's bands: {mean, components (3 x 64), scores (3 arrays), valid (pixel indices), fit (indices)}. */
function fitPca(bands, size) {
const n = size * size, B = bands.length, valid = []
for (let i = 0; i < n; i++) if (bands[0][i] !== NODATA) valid.push(i)
if (valid.length < 2) return null
const step = Math.max(1, Math.floor(valid.length / FIT)), fit = valid.filter((_, k) => k % step === 0)
const mean = new Float64Array(B), cov = new Float64Array(B * B), x = new Float64Array(B)
for (const i of fit) for (let b = 0; b < B; b++) mean[b] += bands[b][i] / fit.length
for (const i of fit) {
for (let b = 0; b < B; b++) x[b] = bands[b][i] - mean[b]
for (let b = 0; b < B; b++) for (let c = b; c < B; c++) cov[b * B + c] += x[b] * x[c]
}
for (let b = 0; b < B; b++) for (let c = 0; c < b; c++) cov[b * B + c] = cov[c * B + b]
const components = leading(cov, B, 3), scores = components.map(() => new Float32Array(n))
for (const i of valid) {
for (let k = 0; k < 3; k++) {
let s = 0
for (let b = 0; b < B; b++) s += (bands[b][i] - mean[b]) * components[k][b]
scores[k][i] = s
}
}
return { mean, components, scores, valid, fit }
}
/** The `count` bands that best predict the three components (forward selection on R², least squares with an
* intercept, from the normal equations), and the weights: rows per chosen band, then the intercept; 3 columns. */
function selectBands(bands, pca, count) {
const B = bands.length, I = B, pixels = pca.fit, Y = pca.scores
const G = new Float64Array((B + 1) * (B + 1)), XY = Array.from({ length: B + 1 }, () => new Float64Array(3))
const value = (b, i) => (b === I ? 1 : bands[b][i])
for (const i of pixels) {
for (let b = 0; b <= B; b++) {
const vb = value(b, i)
for (let c = b; c <= B; c++) G[b * (B + 1) + c] += vb * value(c, i)
for (let k = 0; k < 3; k++) XY[b][k] += vb * Y[k][i]
}
}
for (let b = 0; b <= B; b++) for (let c = 0; c < b; c++) G[b * (B + 1) + c] = G[c * (B + 1) + b]
const explained = set => { // sum over components of the fitted values' sum of squares
const w = solve(set.map(b => set.map(c => G[b * (B + 1) + c])), set.map(b => [...XY[b]]))
if (!w) return [-Infinity, null]
let s = 0
for (let j = 0; j < set.length; j++) for (let k = 0; k < 3; k++) s += w[j][k] * XY[set[j]][k]
return [s, w]
}
const chosen = []
for (let step = 0; step < count; step++) {
let best = -1, bestScore = -Infinity
for (let b = 0; b < B; b++) {
if (chosen.includes(b)) continue
const [s] = explained([...chosen, b, I])
if (s > bestScore) { bestScore = s; best = b }
}
chosen.push(best)
}
return { chosen, weights: explained([...chosen, I])[1] }
}
/** Solves A W = R (A: m x m, R: m x 3) by Gaussian elimination with partial pivoting; null if singular. */
function solve(A, R) {
const m = A.length, a = A.map((row, i) => [...row, ...R[i]])
for (let c = 0; c < m; c++) {
let p = c
for (let r = c + 1; r < m; r++) if (Math.abs(a[r][c]) > Math.abs(a[p][c])) p = r
if (Math.abs(a[p][c]) < 1e-9) return null
;[a[c], a[p]] = [a[p], a[c]]
for (let r = 0; r < m; r++) {
if (r === c) continue
const f = a[r][c] / a[c][c]
for (let k = c; k < m + 3; k++) a[r][k] -= f * a[c][k]
}
}
return a.map((row, i) => row.slice(m).map(v => v / a[i][i]))
}
/** 2nd and 98th percentile of a sample of values. */
function range(values) {
const v = Float64Array.from(values).sort()
if (!v.length) return [0, 1]
const lo = v[Math.floor(v.length * 0.02)], hi = v[Math.floor(v.length * 0.98)]
return hi > lo ? [lo, hi] : [lo, lo + 1]
}
/** Three component images as RGB, each stretched 2-98% over a sample of the valid pixels; the rest transparent. */
function rgbOf(scores, valid, size) {
const out = new Uint8ClampedArray(size * size * 4), step = Math.max(1, Math.floor(valid.length / FIT))
const ranges = scores.map(s => range(valid.filter((_, k) => k % step === 0).map(i => s[i])))
for (const i of valid) {
for (let k = 0; k < 3; k++) out[i * 4 + k] = 255 * Math.min(Math.max((scores[k][i] - ranges[k][0]) / (ranges[k][1] - ranges[k][0]), 0), 1)
out[i * 4 + 3] = 255
}
return out
}
function greyRgba(band, size) {
const out = new Uint8ClampedArray(size * size * 4), picked = []
for (let i = 0; i < band.length; i += Math.max(1, Math.floor(band.length / FIT))) if (band[i] !== NODATA) picked.push(band[i])
const [lo, hi] = range(picked)
for (let i = 0; i < band.length; i++) {
if (band[i] === NODATA) continue
const g = 255 * Math.min(Math.max((band[i] - lo) / (hi - lo), 0), 1)
out[i * 4] = g; out[i * 4 + 1] = g; out[i * 4 + 2] = g; out[i * 4 + 3] = 255
}
return out
}
async function png(rgba, size) {
const canvas = new OffscreenCanvas(size, size)
canvas.getContext('2d').putImageData(new ImageData(rgba, size, size), 0, 0)
return URL.createObjectURL(await canvas.convertToBlob({ type: 'image/png' }))
}
/** The `count` leading eigenvectors of a symmetric B x B matrix: power iteration, deflating after each. Signs are
* fixed (largest loading positive) so the same data gives the same colours. */
function leading(cov, B, count) {
const m = Float64Array.from(cov), out = []
for (let k = 0; k < count; k++) {
let v = Float64Array.from({ length: B }, (_, b) => 1 + b / B), lambda = 0
for (let it = 0; it < 200; it++) {
const w = new Float64Array(B)
for (let b = 0; b < B; b++) { let s = 0; for (let c = 0; c < B; c++) s += m[b * B + c] * v[c]; w[b] = s }
const norm = Math.hypot(...w)
if (!norm) break
lambda = norm; v = w.map(e => e / norm)
}
let big = 0
for (let b = 1; b < B; b++) if (Math.abs(v[b]) > Math.abs(v[big])) big = b
if (v[big] < 0) v = v.map(e => -e)
for (let b = 0; b < B; b++) for (let c = 0; c < B; c++) m[b * B + c] -= lambda * v[b] * v[c]
out.push(v)
}
return out
}
|