use wasm_bindgen::prelude::*;
use crate::cog_reader::{CogMetadata, WasmCogReader};
use crate::to_js_error;
use super::pipeline::{
ChangeOutput, Params, SceneGeo, WindowPlan, assert_alignment, detect_changes_core,
diff_to_rgba, plan_window,
};
use super::utm::UtmZone;
struct Scene {
red: WasmCogReader,
nir: WasmCogReader,
visual: Option<WasmCogReader>,
boa: bool,
geo: SceneGeo,
}
struct ComputeState {
diff: Vec<f32>,
width: u32,
height: u32,
bounds: [f64; 4],
bbox: [f64; 4],
max_dim: u32,
}
#[wasm_bindgen]
pub struct GeoSentinel {
scenes: [Option<Scene>; 2],
last: Option<ComputeState>,
}
impl Default for GeoSentinel {
fn default() -> Self {
Self::new()
}
}
fn scene_geo_from(meta: &CogMetadata) -> Result<SceneGeo, String> {
let epsg = meta
.epsg_code
.ok_or_else(|| "COG is missing a projected EPSG code".to_string())?;
let zone = UtmZone::from_epsg(epsg).ok_or_else(|| {
format!("EPSG {epsg} is not a WGS84 / UTM zone (32601–32660 / 32701–32760)")
})?;
let geo_x = meta
.tiepoint_geo_x
.ok_or_else(|| "COG is missing a model tiepoint (X)".to_string())?;
let geo_y = meta
.tiepoint_geo_y
.ok_or_else(|| "COG is missing a model tiepoint (Y)".to_string())?;
let scale = meta
.pixel_scale_x
.ok_or_else(|| "COG is missing a model pixel scale".to_string())?;
if scale <= 0.0 {
return Err("COG pixel scale is non-positive".to_string());
}
let tie_px = meta.tiepoint_pixel_x.unwrap_or(0.0);
let tie_py = meta.tiepoint_pixel_y.unwrap_or(0.0);
let origin_e = geo_x - tie_px * scale;
let origin_n = geo_y + tie_py * scale;
let level_ratios: Vec<f64> = if meta.levels.is_empty() {
vec![1.0]
} else {
meta.levels
.iter()
.map(|lvl| {
if lvl.width == 0 {
1.0
} else {
meta.width as f64 / lvl.width as f64
}
})
.collect()
};
Ok(SceneGeo {
zone,
origin_e,
origin_n,
pixel_scale: scale,
full_width: meta.width,
full_height: meta.height,
level_ratios,
})
}
#[wasm_bindgen]
impl GeoSentinel {
#[wasm_bindgen(constructor)]
#[must_use]
pub fn new() -> GeoSentinel {
GeoSentinel {
scenes: [None, None],
last: None,
}
}
#[wasm_bindgen(js_name = loadScene)]
pub async fn load_scene(
&mut self,
slot: u8,
red_url: String,
nir_url: String,
visual_url: Option<String>,
boa_offset_applied: bool,
) -> Result<String, JsValue> {
if slot > 1 {
return Err(JsValue::from_str("slot must be 0 or 1"));
}
let red = WasmCogReader::open(red_url)
.await
.map_err(|e| to_js_error(&e))?;
let nir = WasmCogReader::open(nir_url)
.await
.map_err(|e| to_js_error(&e))?;
let visual = match visual_url {
Some(url) => Some(
WasmCogReader::open(url)
.await
.map_err(|e| to_js_error(&e))?,
),
None => None,
};
let geo = scene_geo_from(red.metadata()).map_err(|e| JsValue::from_str(&e))?;
let summary = serde_json::json!({
"slot": slot,
"epsg": geo.zone.epsg(),
"width": geo.full_width,
"height": geo.full_height,
"levels": geo.level_ratios.len(),
"boaOffsetApplied": boa_offset_applied,
})
.to_string();
self.scenes[slot as usize] = Some(Scene {
red,
nir,
visual,
boa: boa_offset_applied,
geo,
});
Ok(summary)
}
#[wasm_bindgen(js_name = detectChanges)]
pub async fn detect_changes(&mut self, params_json: String) -> Result<String, JsValue> {
let params = Params::from_json(¶ms_json).map_err(|e| JsValue::from_str(&e))?;
let boa_a = self.scene(0)?.boa;
let boa_b = self.scene(1)?.boa;
let plan: WindowPlan = {
let a = self.scene(0)?;
let b = self.scene(1)?;
assert_alignment(&a.geo, &b.geo).map_err(|e| JsValue::from_str(&e))?;
plan_window(&a.geo, params.bbox, params.max_dim).map_err(|e| JsValue::from_str(&e))?
};
let (level, x0, y0, w, h) = (plan.level, plan.x0, plan.y0, plan.w, plan.h);
let red_a = self
.scene(0)?
.red
.read_window_u16(level, x0, y0, w, h)
.await
.map_err(|e| to_js_error(&e))?;
let nir_a = self
.scene(0)?
.nir
.read_window_u16(level, x0, y0, w, h)
.await
.map_err(|e| to_js_error(&e))?;
let red_b = self
.scene(1)?
.red
.read_window_u16(level, x0, y0, w, h)
.await
.map_err(|e| to_js_error(&e))?;
let nir_b = self
.scene(1)?
.nir
.read_window_u16(level, x0, y0, w, h)
.await
.map_err(|e| to_js_error(&e))?;
let out: ChangeOutput = detect_changes_core(
&red_a, &nir_a, &red_b, &nir_b, w, h, ¶ms, &plan, boa_a, boa_b,
)
.map_err(|e| JsValue::from_str(&e))?;
let result = serde_json::json!({
"totalHa": out.total_ha,
"polygonCount": out.polygon_count,
"thresholdUsed": out.threshold_used,
"level": level,
"windowPx": [x0, y0, w, h],
"boundsWgs84": plan.bounds_wgs84,
"fc": out.fc,
})
.to_string();
self.last = Some(ComputeState {
diff: out.diff,
width: out.width,
height: out.height,
bounds: plan.bounds_wgs84,
bbox: params.bbox,
max_dim: params.max_dim,
});
Ok(result)
}
#[wasm_bindgen(js_name = diffOverlayRgba)]
pub fn diff_overlay_rgba(&self) -> Result<Vec<u8>, JsValue> {
let state = self
.last
.as_ref()
.ok_or_else(|| JsValue::from_str("no detection has been run yet"))?;
Ok(diff_to_rgba(&state.diff))
}
#[wasm_bindgen(js_name = overlayInfo)]
pub fn overlay_info(&self) -> Result<String, JsValue> {
let state = self
.last
.as_ref()
.ok_or_else(|| JsValue::from_str("no detection has been run yet"))?;
Ok(serde_json::json!({
"width": state.width,
"height": state.height,
"boundsWgs84": state.bounds,
})
.to_string())
}
#[wasm_bindgen(js_name = trueColorRgba)]
pub async fn true_color_rgba(&mut self, slot: u8) -> Result<TrueColorImage, JsValue> {
if slot > 1 {
return Err(JsValue::from_str("slot must be 0 or 1"));
}
let (bbox, max_dim) = {
let state = self
.last
.as_ref()
.ok_or_else(|| JsValue::from_str("no detection has been run yet"))?;
(state.bbox, state.max_dim)
};
let plan = {
let scene = self.scene(slot)?;
let visual = scene
.visual
.as_ref()
.ok_or_else(|| JsValue::from_str("scene has no true-colour asset"))?;
let vgeo = scene_geo_from(visual.metadata()).map_err(|e| JsValue::from_str(&e))?;
plan_window(&vgeo, bbox, max_dim).map_err(|e| JsValue::from_str(&e))?
};
let rgb = self
.scene(slot)?
.visual
.as_ref()
.ok_or_else(|| JsValue::from_str("scene has no true-colour asset"))?
.read_window_rgb8(plan.level, plan.x0, plan.y0, plan.w, plan.h)
.await
.map_err(|e| to_js_error(&e))?;
let mut data = Vec::with_capacity(rgb.len() / 3 * 4);
for px in rgb.chunks_exact(3) {
data.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
Ok(TrueColorImage {
data,
width: plan.w,
height: plan.h,
})
}
}
#[wasm_bindgen]
pub struct TrueColorImage {
data: Vec<u8>,
width: u32,
height: u32,
}
#[wasm_bindgen]
impl TrueColorImage {
#[wasm_bindgen(getter)]
pub fn data(&self) -> Vec<u8> {
self.data.clone()
}
#[wasm_bindgen(getter)]
pub fn width(&self) -> u32 {
self.width
}
#[wasm_bindgen(getter)]
pub fn height(&self) -> u32 {
self.height
}
}
impl GeoSentinel {
fn scene(&self, slot: u8) -> Result<&Scene, JsValue> {
self.scenes
.get(slot as usize)
.and_then(Option::as_ref)
.ok_or_else(|| JsValue::from_str(&format!("scene slot {slot} is not loaded")))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::cog_reader::IfdMetadata;
fn ifd(width: u64, height: u64) -> IfdMetadata {
IfdMetadata {
width,
height,
tile_width: 512,
tile_height: 512,
bits_per_sample: 16,
samples_per_pixel: 1,
sample_format: 1,
compression: 8,
photometric_interpretation: 1,
predictor: 1,
tile_offsets: Vec::new(),
tile_byte_counts: Vec::new(),
pixel_scale_x: Some(10.0),
pixel_scale_y: Some(10.0),
tiepoint_pixel_x: Some(0.0),
tiepoint_pixel_y: Some(0.0),
tiepoint_geo_x: Some(399_960.0),
tiepoint_geo_y: Some(3_900_000.0),
epsg_code: Some(32654),
}
}
fn meta_with_levels() -> CogMetadata {
CogMetadata {
width: 10_980,
height: 10_980,
tile_width: 512,
tile_height: 512,
bits_per_sample: 16,
samples_per_pixel: 1,
sample_format: 1,
compression: 8,
photometric_interpretation: 1,
predictor: 1,
tile_offsets: Vec::new(),
tile_byte_counts: Vec::new(),
pixel_scale_x: Some(10.0),
pixel_scale_y: Some(10.0),
tiepoint_pixel_x: Some(0.0),
tiepoint_pixel_y: Some(0.0),
tiepoint_geo_x: Some(399_960.0),
tiepoint_geo_y: Some(3_900_000.0),
overview_count: 3,
overviews: Vec::new(),
epsg_code: Some(32654),
levels: vec![
ifd(10_980, 10_980),
ifd(5_490, 5_490),
ifd(2_745, 2_745),
ifd(1_373, 1_373),
],
}
}
#[test]
fn scene_geo_from_metadata() {
let geo = scene_geo_from(&meta_with_levels()).expect("geo");
assert_eq!(geo.zone, UtmZone::from_epsg(32654).unwrap());
assert!((geo.origin_e - 399_960.0).abs() < 1e-9);
assert!((geo.origin_n - 3_900_000.0).abs() < 1e-9);
assert!((geo.pixel_scale - 10.0).abs() < 1e-9);
assert_eq!(geo.full_width, 10_980);
assert_eq!(geo.level_ratios.len(), 4);
assert!((geo.level_ratios[0] - 1.0).abs() < 1e-9);
assert!((geo.level_ratios[1] - 2.0).abs() < 0.01);
assert!((geo.level_ratios[3] - 8.0).abs() < 0.05);
}
#[test]
fn scene_geo_from_rejects_non_utm() {
let mut meta = meta_with_levels();
meta.epsg_code = Some(4326);
assert!(scene_geo_from(&meta).is_err());
meta.epsg_code = None;
assert!(scene_geo_from(&meta).is_err());
}
#[test]
fn scene_geo_honours_tiepoint_offset() {
let mut meta = meta_with_levels();
meta.tiepoint_pixel_x = Some(2.0);
meta.tiepoint_pixel_y = Some(3.0);
let geo = scene_geo_from(&meta).expect("geo");
assert!((geo.origin_e - (399_960.0 - 20.0)).abs() < 1e-9);
assert!((geo.origin_n - (3_900_000.0 + 30.0)).abs() < 1e-9);
}
#[test]
fn true_color_image_getters_and_invariant() {
let width = 4u32;
let height = 3u32;
let data = vec![7u8; (width * height * 4) as usize];
let img = TrueColorImage {
data: data.clone(),
width,
height,
};
assert_eq!(img.width(), width);
assert_eq!(img.height(), height);
assert_eq!(img.data(), data);
assert_eq!(
img.data().len() as u32,
img.width() * img.height() * 4,
"data length must always be width * height * 4"
);
}
#[test]
fn true_color_image_non_square_invariant() {
let width = 517u32;
let height = 233u32;
let data = vec![0u8; (width * height * 4) as usize];
let img = TrueColorImage {
data,
width,
height,
};
assert_eq!(img.data().len() as u32, width * height * 4);
assert_eq!(img.width(), 517);
assert_eq!(img.height(), 233);
}
}