diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index b15e086da..ca83638a6 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -663,6 +663,7 @@ pub(crate) enum FilesAhead { pub(crate) struct RanAheads { tools: Vec, files: Vec, + zones: Vec, } #[cfg(not(target_arch = "wasm32"))] @@ -673,9 +674,11 @@ pub(crate) struct RanAheads { pub(crate) fn taking(s: &mut Session, ran: RanAheads, run: impl FnOnce(&mut Session) -> T) -> T { s.tools_ahead = Some(Ahead::Taking(ran.tools.into())); s.files_ahead = Some(FilesAhead::Taking(ran.files.into())); + s.zones_ahead = Some(super::imagery::ZonesAhead::Taking(ran.zones.into())); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(s))); s.tools_ahead = None; s.files_ahead = None; + s.zones_ahead = None; r.unwrap_or_else(|p| std::panic::resume_unwind(p)) } @@ -690,7 +693,11 @@ pub(crate) fn ran_ahead(s: &mut Session) -> RanAheads { Some(FilesAhead::Recording(read)) => read, _ => Vec::new(), }; - RanAheads { tools, files } + let zones = match s.zones_ahead.take() { + Some(super::imagery::ZonesAhead::Recording(read)) => read, + _ => Vec::new(), + }; + RanAheads { tools, files, zones } } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/imagery.rs b/crates/engine/src/cmd/imagery.rs index 4c119307a..6e9605167 100644 --- a/crates/engine/src/cmd/imagery.rs +++ b/crates/engine/src/cmd/imagery.rs @@ -921,12 +921,15 @@ fn spectral_points(s: &mut Session, r: &Raster, points: &Value) -> Result CmdResult { let r = raster(s, p)?; // A zone is read before points, and points before one point: what isn't read is said, once @@ -950,7 +953,7 @@ fn spectral(s: &mut Session, p: &Value) -> CmdResult { /// [`spectral`]'s answer: the mean in the polygon layer `zone` when given, else the points' /// profiles, else one point's. -fn spectral_read(s: &mut Session, p: &Value, r: &Raster, zone: Option<&str>) -> CmdResult { +fn spectral_read(s: &mut Session, p: &Value, r: &Arc, zone: Option<&str>) -> CmdResult { let all: Vec = (0..r.band_count()).collect(); let names: Vec = r.bands.iter().map(|b| b.name.clone()).collect(); // The bands' wavelengths, when the file gives any (`null` for a band without one). @@ -967,17 +970,10 @@ fn spectral_read(s: &mut Session, p: &Value, r: &Raster, zone: Option<&str>) -> return answer(json!({"bands": names, "profiles": profiles})); } if let Some(z) = zone { - let l = s - .map() - .layer_by_name(z) - .filter(|l| l.geometry_type() == GeometryType::Polygon) - .ok_or_else(|| format!("no polygon layer '{z}'"))?; - let row = l.selection.first().copied().unwrap_or(0); - let Some(Geometry::Polygon(polys)) = l.dataset.features.get(row).and_then(|f| f.geometry.as_ref()) else { - return Err("the zone has no polygon".into()); - }; - let rings = polys.first().ok_or("the zone has no polygon")?; - let (means, n) = rasterops::zonal_means(r, &all, rings)?.ok_or("no pixels fall in the zone")?; + let rings = zone_rings(s, z)?; + // Read on a thread of its own as the app sent the command, when still of this raster and + // this polygon; here otherwise. + let (means, n) = zone_means(s, r, rings)?.ok_or("no pixels fall in the zone")?; return answer(json!({"bands": names, "values": means, "pixels": n})); } let c = Coord::new(f(p, "lon").ok_or("give lon and lat, points or a zone")?, f(p, "lat").ok_or("lat is required")?); @@ -986,6 +982,64 @@ fn spectral_read(s: &mut Session, p: &Value, r: &Raster, zone: Option<&str>) -> answer(json!({"bands": names, "values": vals, "pixel": [col, row]})) } +/// The polygon a spectral zone names: the polygon layer `z`'s selected feature, or its first. +fn zone_rings(s: &Session, z: &str) -> Result>, String> { + let l = + s.map().layer_by_name(z).filter(|l| l.geometry_type() == GeometryType::Polygon).ok_or_else(|| format!("no polygon layer '{z}'"))?; + let row = l.selection.first().copied().unwrap_or(0); + let Some(Geometry::Polygon(polys)) = l.dataset.features.get(row).and_then(|f| f.geometry.as_ref()) else { + return Err("the zone has no polygon".into()); + }; + polys.first().cloned().ok_or_else(|| "the zone has no polygon".into()) +} + +/// A zone's means, every band's: those a script's copy of the session read, when of this raster +/// and this polygon; read here otherwise (and recorded, on that copy, for the script's run here to +/// take). A raster a tool made earlier in the script is decoded apart on each side, so its zones +/// are read again here. +#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] +fn zone_means(s: &mut Session, r: &Arc, rings: Vec>) -> Result, usize)>, String> { + #[cfg(not(target_arch = "wasm32"))] + let same = |z: &ZoneMeans| Arc::ptr_eq(&z.raster, r) && z.rings == rings; + #[cfg(not(target_arch = "wasm32"))] + if let Some(ZonesAhead::Taking(zones)) = &mut s.zones_ahead { + match zones.pop_front() { + Some(next) if same(&next) => { + #[cfg(test)] + { + s.zones_taken += 1; + } + return next.means; + } + _ => s.zones_ahead = None, + } + } + let all: Vec = (0..r.band_count()).collect(); + let means = rasterops::zonal_means(r, &all, &rings).map_err(String::from); + #[cfg(not(target_arch = "wasm32"))] + if let Some(ZonesAhead::Recording(zones)) = &mut s.zones_ahead { + zones.push(ZoneMeans { raster: r.clone(), rings, means: means.clone() }); + } + means +} + +#[cfg(not(target_arch = "wasm32"))] +/// The zones' means a script's run on a copy of the session read: recorded on the copy, then +/// taken in order by the run here, as its tools' results are ([`super::gp::Ahead`]). +pub(crate) enum ZonesAhead { + Recording(Vec), + Taking(std::collections::VecDeque), +} + +/// A zone's means read on a script's copy of the session, with what they were read from, so the +/// run here takes them only for the same raster and polygon. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct ZoneMeans { + raster: Arc, + rings: Vec>, + means: Result, usize)>, String>, +} + fn supervised(s: &mut Session, p: &Value) -> CmdResult { super::workflows::made_or_ran(s, p, "raster.classifySupervised") } diff --git a/crates/engine/src/cmd/workflows.rs b/crates/engine/src/cmd/workflows.rs index 80561d751..95b105104 100644 --- a/crates/engine/src/cmd/workflows.rs +++ b/crates/engine/src/cmd/workflows.rs @@ -643,6 +643,7 @@ pub(crate) fn ahead_on_copy(s: &Session, command: &'static str, p: &Value) -> su copy.active_layer = active_layer; copy.tools_ahead = Some(super::gp::Ahead::Recording(Vec::new())); copy.files_ahead = Some(super::gp::FilesAhead::Recording(Vec::new())); + copy.zones_ahead = Some(super::imagery::ZonesAhead::Recording(Vec::new())); let _ = copy.run(command, &p); super::gp::ran_ahead(&mut copy) }) @@ -685,6 +686,7 @@ pub(crate) const ON_A_COPY: &[&str] = &[ "layer.definitionQuery", "layer.select", "map.projection", + "raster.spectral", ]; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50b213aa3..780157718 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -909,6 +909,10 @@ pub struct Session { /// ([`cmd::gp::FilesAhead`]). #[cfg(not(target_arch = "wasm32"))] pub(crate) files_ahead: Option, + /// The zones' spectral means a script read on that copy, which its run here takes + /// ([`cmd::imagery::ZonesAhead`]). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) zones_ahead: Option, /// Where the requests a copy of the session makes for a model's run ahead go /// ([`cmd::workflows::ahead_on_copy`]): the session's own Diagnostics, as when they're made /// there. Its notices stay its own: the run there says them. @@ -917,6 +921,9 @@ pub struct Session { /// The tools run ahead that a model's run took, for the tests. #[cfg(test)] pub(crate) tools_taken: usize, + /// The zones' means read ahead that a command took, for the tests. + #[cfg(test)] + pub(crate) zones_taken: usize, /// Set where a person at a terminal waits on a command (`rustgis-cli run` with stdin and /// stderr a terminal), who can stop it with Ctrl-C: tools and rasters take no work cap /// ([`rustgis_analysis::control::Control::stoppable`]). A program that calls it (an agent, a @@ -1207,9 +1214,13 @@ impl Session { #[cfg(not(target_arch = "wasm32"))] files_ahead: None, #[cfg(not(target_arch = "wasm32"))] + zones_ahead: None, + #[cfg(not(target_arch = "wasm32"))] requests_into: None, #[cfg(test)] tools_taken: 0, + #[cfg(test)] + zones_taken: 0, stoppable: false, }; for text in label_notices { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fa33d2ba1..1f5eea6eb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -38293,3 +38293,62 @@ fn a_models_geolibre_step_runs_ahead_and_is_taken() { assert_eq!(s.tools_taken, 1, "the GeoLibre step was taken, not run again here"); assert!(s.map().layer_by_name("Slope").is_some()); } + +/// A script's zone of spectral means is read on the script's copy of the session, off the UI +/// thread (#1562), and the run here takes it: the same answer as read here. A zone changed +/// meanwhile is read again here, and a cancelled run looks at Cancel as it reads the zone. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_scripts_spectral_zone_is_read_on_its_copy() { + use crate::cmd::reads::{read_ahead, read_land}; + use base64::Engine; + use rustgis_analysis::control::Control; + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let enc = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b); + let gt = GeoTransform { origin_x: 10.0, origin_y: 45.0, pixel_width: 0.01, pixel_height: -0.01 }; + let bands = (0..3).map(|b| (0..16).map(|i| (30 - b * 10 + i) as f32).collect()).collect(); + let r = Raster::new("scene", 4, 4, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt8").unwrap(); + let tiff = enc(&geotiff::write(&r, 0).unwrap()); + let zone = |x1: f64| { + enc(json!({"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": + {"type": "Polygon", "coordinates": [[[10.0, 45.0], [x1, 45.0], [x1, 44.98], [10.0, 44.98], [10.0, 45.0]]]}}]}) + .to_string() + .as_bytes()) + }; + let session = || { + let mut s = Session::new(Project::default()); + s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": tiff})).unwrap(); + s.run("layer.addDataBytes", &json!({"fileName": "zone.geojson", "data": zone(10.02)})).unwrap(); + s + }; + let p = json!({"layer": "scene", "zone": "zone"}); + let here = session().run("raster.spectral", &p).unwrap(); + assert_eq!(here["pixels"], 4, "{here}"); + + let script = json!({"script": format!("raster.spectral {p}")}); + let mut s = session(); + let ahead = read_ahead(&mut s, "script.run", &script).unwrap(); + assert!(ahead.reads(), "the script runs on a copy"); + let read = ahead.run(&Control::stoppable()).unwrap(); + let v = read_land(&mut s, read).unwrap(); + assert_eq!(s.zones_taken, 1); + assert!(s.zones_ahead.is_none()); + assert_eq!(v["ran"][0]["result"], here, "{v}"); + + // The zone changed while the copy ran: read again here, on the zone as it is now. + let mut s = session(); + let read = read_ahead(&mut s, "script.run", &script).unwrap().run(&Control::stoppable()).unwrap(); + s.run("layer.remove", &json!({"layer": "zone"})).unwrap(); + s.run("layer.addDataBytes", &json!({"fileName": "zone.geojson", "data": zone(10.04)})).unwrap(); + let now = read_land(&mut s, read).unwrap(); + assert_eq!(s.zones_taken, 0); + let direct = s.run("raster.spectral", &p).unwrap(); + assert_eq!(direct["pixels"], 8, "{direct}"); + assert_eq!(now["ran"][0]["result"], direct, "the zone as it is now"); + + // Cancelled as the copy starts: the zone's read looks at it, and nothing lands. + let mut s = session(); + let control = Control::cancelled_once_started(); + assert!(read_ahead(&mut s, "script.run", &script).unwrap().run(&control).is_err()); + assert!(control.looked() > 0, "the zone's read looked at Cancel"); +} diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 305e4903f..05685b017 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -1284,7 +1284,7 @@ project (New, Open, the sample, a template, or the same one again), from the app assistant or an agent, starts the pane blank: the script, its results, the count and the Details window go with the old project. A script that opens a project itself stays, with its results, as the new project's Run #1. On the desktop, a script's tools, models and batches, with selections, layer -renames and definition queries, the environment and the projection between them, are worked out +renames and definition queries, the environment, the projection and spectral zones between them, are worked out in the background first, so the map and the rest of the window stay in use: the status bar shows **Run Script** and **Cancel**, and the results say **Running…** until the run is added. From the first line of any other kind (an edit, Add Data, a save, an export, a style or a zoom, a plugin @@ -1462,7 +1462,9 @@ The Analyze tab's imagery groups open their tools in the Workflows pane: (desktop) writes a row per band (its number, name and wavelength) and a column per point. From scripts, `{"layer": "scene", "points": [[-43.95, -19.92], [-43.96, -19.93]]}` gives one profile per point (up to 1,000; a point off the raster keeps its place with null values, and is said), - `{"lon", "lat"}` one pixel and `{"zone": "polygons"}` the mean in the selected or first polygon; + `{"lon", "lat"}` one pixel and `{"zone": "polygons"}` the mean in the selected or first polygon + (on the desktop, a script's zone is worked out in the background with the script, up to the + first line that stops it, as said under **Script**; one after it holds the window); every answer has the band names and, when the file gives any, `"wavelengths"` in nanometres. After GeoLibre's spectral profile. - **Change Detection** (difference, ratio, percent), **Indices** (NDVI, NDWI, SAVI, NBR, NDBI,