Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/engine/src/cmd/gp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ pub(crate) enum FilesAhead {
pub(crate) struct RanAheads {
tools: Vec<RanAhead>,
files: Vec<FileAhead>,
zones: Vec<super::imagery::ZoneMeans>,
}

#[cfg(not(target_arch = "wasm32"))]
Expand All @@ -673,9 +674,11 @@ pub(crate) struct RanAheads {
pub(crate) fn taking<T>(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))
}

Expand All @@ -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"))]
Expand Down
90 changes: 72 additions & 18 deletions crates/engine/src/cmd/imagery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,12 +921,15 @@ fn spectral_points(s: &mut Session, r: &Raster, points: &Value) -> Result<Vec<Va
/// Every band's value at a point, at several points (`points`, a profile each), or their mean
/// in a polygon (`zone`), with the bands' names and wavelengths.
///
/// It runs where it is called, on the session's thread (#1562). The point forms read at most
/// [`MAX_SPECTRAL_POINTS`] pixels, and the Image Analysis pane asks only for points. A zone reads
/// the cells in the zone's window, row by row, and looks at Cancel between rows, but nothing in
/// the app runs this command under a control yet: a script, model or the assistant asking for a
/// large zone holds the window until the means are read. Moving it to a background read is left
/// for when that is asked for.
/// The point forms read at most [`MAX_SPECTRAL_POINTS`] pixels where the command is called. A
/// zone's means grow with the zone. A script's line reads them on the script's copy of the session
/// (it is on `ON_A_COPY`), off the UI thread with **Run Script**'s progress and **Cancel**, and the
/// run here takes them (#1562); from the first line that stops the copy, a script's zones are read
/// here, as its tools run here. No pane of the app asks for a zone. The assistant, tasks and the
/// command line call commands one at a time on the session itself and use each answer before the
/// next call, so there is no copy to read on: a zone they ask for is read where they ask, looking at
/// Cancel between rows when it runs under a control. Giving those callers a background path is their
/// work, for every command they call, not this command's.
fn spectral(s: &mut Session, p: &Value) -> CmdResult {
let r = raster(s, p)?;
// A zone is read before points, and points before one point: what isn't read is said, once
Expand All @@ -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<Raster>, zone: Option<&str>) -> CmdResult {
let all: Vec<usize> = (0..r.band_count()).collect();
let names: Vec<String> = r.bands.iter().map(|b| b.name.clone()).collect();
// The bands' wavelengths, when the file gives any (`null` for a band without one).
Expand All @@ -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")?);
Expand All @@ -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<Vec<Vec<Coord>>, 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<Raster>, rings: Vec<Vec<Coord>>) -> Result<Option<(Vec<f64>, 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<usize> = (0..r.band_count()).collect();
let means = rasterops::zonal_means(r, &all, &rings).map_err(String::from);
Comment thread
pick2510 marked this conversation as resolved.
Resolved
#[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<ZoneMeans>),
Taking(std::collections::VecDeque<ZoneMeans>),
}

/// 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<Raster>,
rings: Vec<Vec<Coord>>,
means: Result<Option<(Vec<f64>, usize)>, String>,
}

fn supervised(s: &mut Session, p: &Value) -> CmdResult {
super::workflows::made_or_ran(s, p, "raster.classifySupervised")
}
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -685,6 +686,7 @@ pub(crate) const ON_A_COPY: &[&str] = &[
"layer.definitionQuery",
"layer.select",
"map.projection",
"raster.spectral",
];

#[cfg(not(target_arch = "wasm32"))]
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,10 @@ pub struct Session {
/// ([`cmd::gp::FilesAhead`]).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) files_ahead: Option<cmd::gp::FilesAhead>,
/// 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<cmd::imagery::ZonesAhead>,
/// 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.
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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");
}
6 changes: 4 additions & 2 deletions docs/user-guide/analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading