geowarp
Version:
Super Low-Level Raster Reprojection and Resampling Library
1,010 lines (884 loc) • 41.9 kB
JavaScript
const { booleanIntersects, calc: getBoundingBox, intersect, polygon } = require("bbox-fns");
const dufour_peyton_intersection = require("dufour-peyton-intersection");
const fastMax = require("fast-max");
const fastMin = require("fast-min");
const Geotransform = require("geoaffine/Geotransform.js");
const getDepth = require("get-depth");
const getTheoreticalMax = require("typed-array-ranges/get-max");
const getTheoreticalMin = require("typed-array-ranges/get-min");
const calcMedian = require("mediana").calculate;
const reprojectBoundingBox = require("bbox-fns/reproject.js");
const reprojectGeoJSON = require("reproject-geojson/pluggable");
const { turbocharge } = require("proj-turbo");
const quickResolve = require("quick-resolve");
const segflip = require("segflip");
const xdim = require("xdim");
// l = console.log;
const clamp = (n, min, max) => (n < min ? min : n > max ? max : n);
const isInvalid = n => n === undefined || n === null || n !== n;
const scaleInteger = (n, r) => {
const n2 = Math.round(n * r);
return [n2, n2 / n, n / n2];
};
// result as [xmin, ymin, xmax, ymax]
// for (let column = xmin; column < xmax; column++)
const scalePixel = ([column, row], [x_scale, y_scale]) => [
Math.round(column * x_scale),
Math.round(row * y_scale),
Math.round((column + 1) * x_scale),
Math.round((row + 1) * y_scale)
];
const uniq = arr => Array.from(new Set(arr)).sort((a, b) => b - a);
const range = ct => new Array(ct).fill(0).map((_, i) => i);
const forEach = (nums, no_data, cb) => {
const len = nums.length;
if (no_data) {
for (let i = 0; i < len; i++) {
const n = nums[i];
if (no_data.indexOf(n) === -1) cb(n);
}
} else {
for (let i = 0; i < len; i++) {
cb(nums[i]);
}
}
};
const mean = (nums, in_no_data) => {
let running_sum = 0;
let count = 0;
forEach(nums, in_no_data, n => {
count++;
running_sum += n;
});
return count === 0 ? undefined : running_sum / count;
};
const mode = (nums, no_data) => {
if (nums.length === 0) return undefined;
const counts = {};
if (no_data) {
for (let i = 0; i < nums.length; i++) {
const n = nums[i];
if (typeof n === "number" && n === n && no_data.indexOf(n) === -1) {
if (n in counts) counts[n].count++;
else counts[n] = { n, count: 1 };
}
}
} else {
for (let i = 0; i < nums.length; i++) {
const n = nums[i];
if (n in counts) counts[n].count++;
else counts[n] = { n, count: 1 };
}
}
const items = Object.values(counts);
const count = items.sort((a, b) => Math.sign(b.count - a.count))[0].count;
return items.filter(it => it.count === count).map(it => it.n);
};
// returns [functionCached, clearCache]
const cacheFunction = (f, str = it => it.toString()) => {
let cache = {};
return [xy => (cache[str(xy)] ??= f(xy)), () => (cache = {})];
};
// generate a histogram from evenly spaced sample points
// purpose is to give us a sense of the distribution of pixel values
// without spending a lot of time reading every point
const quickHistogram = ({ select, width, height }, [across, down]) => {
const hist = {};
const x_scale = width / across;
const y_scale = height / down;
const rows = new Array(down).fill(null).map((_, i) => Math.floor(i * y_scale));
const cols = new Array(across).fill(null).map((_, i) => Math.floor(i * x_scale));
rows.forEach(row => {
cols.forEach(column => {
const value = select({ row, column });
if (value in hist) hist[value]++;
else hist[value] = 1;
});
});
return Object.entries(hist).sort(([apx, act], [bpx, bct]) => Math.sign(bct - act));
};
const geowarp = function geowarp({
debug_level = 0,
in_data,
in_bbox = undefined,
in_geotransform = undefined, // 6-parameter geotransform, only necessary when in_data is skewed or rotated
in_layout = "[band][row,column]",
in_srs,
in_height,
in_pixel_depth, // number of input bands
in_pixel_height, // optional, automatically calculated from in_bbox
in_pixel_width, // optional, automatically calculated from in_bbox
in_width,
in_no_data, // optional, supports one number or an array of unique no data values
out_array_types, // array of constructor names passed to internal call to xdim's prepareData function
out_bands, // array of bands to keep and order, default is keeping all the bands in same order
out_data, // single or multi-dimensional array that geowarp will fill in with the output
out_pixel_depth, // optional, number of output bands
out_pixel_height, // optional, automatically calculated from out_bbox
out_pixel_width, // optional, automatically calculated from out_bbox
out_bbox = null,
out_bbox_in_srs, // very optional, output bbox reprojected into the srs of the input
out_layout,
out_resolution = [1, 1],
out_srs,
out_width = 256,
out_height = 256,
out_no_data = null,
// out_no_data_strategy = "keep",
method = "median",
read_bands = undefined, // which bands to read, used in conjunction with expr
row_start = 0, // which sample row to start writing with
row_end, // last sample row to write
expr = undefined, // band expression function
round = false, // whether to round output
theoretical_min, // minimum theoretical value (e.g., 0 for unsigned integer arrays)
theoretical_max, // maximum values (e.g., 255 for 8-bit unsigned integer arrays),
inverse, // function to reproject [x, y] point from out_srs back to in_srs
forward, // function to reproject [x, y] point from in_srs to out_srs
cutline, // polygon or polygons defining areas to cut out (everything outside becomes no data)
cutline_bbox, // bounding box of the cutline geometry, can lead to a performance increase when combined with turbo
cutline_srs, // spatial reference system of the cutline
cutline_forward, // function to reproject [x, y] point from cutline_srs to out_srs
cutline_strategy = "outside", // cut out the pixels inside or outside the cutline
turbo = false, // enable experimental turbocharging via proj-turbo
insert_pixel, // over-ride function that inserts data into output multi-dimensional array
insert_sample, // over-ride function that inserts each sample into the output multi-dimensional array (calls insert)
insert_null_strategy = "skip", // whether to insert or skip null values
skip_no_data_strategy, // skip processing pixels if "any" or "all" values are "no data"
cache_process = false // whether to try to cache the processing step
// cache_functions // this really helps if functions are asynchronous and require posting to a web worker
}) {
if (debug_level >= 1) console.log("[geowarp] starting");
const start_time = debug_level >= 1 ? performance.now() : 0;
if (isNaN(out_height)) throw new Error("[geowarp] out_height is NaN");
if (isNaN(out_width)) throw new Error("[geowarp] out_width is NaN");
// track pending promises without the memory overhead
// of holding all the promises in memory
let pending = 0;
const [out_height_in_samples, y_resolution, y_scale] = scaleInteger(out_height, out_resolution[1]);
const [out_width_in_samples, x_resolution, x_scale] = scaleInteger(out_width, out_resolution[0]);
if (debug_level >= 1) console.log("[geowarp] scaled size:", [out_width_in_samples, out_height_in_samples]);
if (debug_level >= 1) console.log("[geowarp] resolution:", [x_resolution, y_resolution]);
if (debug_level >= 1) console.log("[geowarp] scale:", [x_scale, y_scale]);
const same_srs = in_srs === out_srs;
if (debug_level >= 1) console.log("[geowarp] input and output srs are the same:", same_srs);
if (debug_level >= 1) console.log("[geowarp] skip_no_data_strategy:", skip_no_data_strategy);
// support for deprecated alias of inverse
inverse ??= arguments[0].reproject;
// support for deprecated insert
insert_pixel ??= arguments[0].insert;
let in_bbox_out_srs, intersect_bbox_in_srs, intersect_bbox_out_srs;
if (!same_srs) {
if (!in_bbox) throw new Error("[geowarp] can't reproject without in_bbox");
if (!out_bbox) {
if (forward) out_bbox = in_bbox_out_srs = intersect_bbox_out_srs = reprojectBoundingBox(in_bbox, forward, { density: 100 });
else throw new Error("[geowarp] must specify out_bbox or forward");
}
}
if (!same_srs && typeof inverse !== "function") {
throw new Error("[geowarp] you must specify a reproject function");
}
if (!in_height) throw new Error("[geowarp] you must provide in_height");
if (!in_width) throw new Error("[geowarp] you must provide in_width");
// if no output layout is specified
// just return the data in the same layout as it is provided
if (!out_layout) out_layout = in_layout;
if (in_pixel_depth === undefined || in_pixel_depth === null) {
if (in_layout.startsWith("[band]")) {
in_pixel_depth = in_data.length;
} else {
const depth = getDepth(in_data);
if (depth === 1) {
// could be [row,column,band] or [band,row,column]
in_pixel_depth = in_data.length / in_height / in_width;
} else if (depth === 2) {
// probably [row,column][band]
in_pixel_depth = in_data[0].length;
} else if (depth === 3) {
// probably [row][column][band]
in_pixel_depth = in_data[0][0].length;
}
}
}
if (debug_level >= 1) console.log("[geowarp] number of bands in source data:", in_pixel_depth);
if (!read_bands) {
if (expr) read_bands = range(in_pixel_depth);
else if (out_bands) read_bands = uniq(out_bands);
else read_bands = range(in_pixel_depth);
}
out_bands ??= read_bands;
if (round && typeof out_no_data === "number") out_no_data = Math.round(out_no_data);
// if (out_no_data === null && out_no_data_strategy === "keep") out_no_data = in_no_data;
if (Array.isArray(in_no_data) === false) {
if ("in_no_data" in arguments[0]) {
in_no_data = [in_no_data];
} else {
in_no_data = [];
}
}
const primary_in_no_data = in_no_data[0];
// processing step after we have read the raw pixel values
let process;
if (expr) {
if (round) {
process = ({ pixel }) => quickResolve(expr({ pixel })).then(pixel => pixel.map(n => Math.round(n)));
} else {
process = expr; // maps ({ pixel }) to new pixel
}
} else {
// mapping index of band in output pixel to index in read band
const out_bands_to_read_bands = out_bands.map(iband => read_bands.indexOf(iband));
// we create a different processing pipeline depending on rounding
// because we don't want to check if we should round for every single pixel
if (round) {
process = ({ pixel }) =>
out_bands_to_read_bands.map(iband => {
const value = pixel[iband];
return isInvalid(value) || in_no_data.includes(value) ? out_no_data : Math.round(value);
});
} else {
// without rounding
process = ({ pixel }) =>
out_bands_to_read_bands.map(iband => {
const value = pixel[iband];
return isInvalid(value) || in_no_data.includes(value) ? out_no_data : value;
});
}
}
let clear_process_cache;
if (cache_process) {
// eslint-disable-next-line no-unused-vars
[process, clear_process_cache] = cacheFunction(process, ({ pixel }) => pixel.toString());
}
if (debug_level >= 1) console.log("[geowarp] read_bands:", read_bands);
if (debug_level >= 1) console.log("[geowarp] out_height:", out_height);
if (debug_level >= 1) console.log("[geowarp] out_width:", out_width);
if (same_srs && in_bbox && !out_bbox) {
out_bbox = in_bbox;
}
const [in_xmin, in_ymin, in_xmax, in_ymax] = in_bbox;
in_pixel_height ??= (in_ymax - in_ymin) / in_height;
in_pixel_width ??= (in_xmax - in_xmin) / in_width;
if (debug_level >= 1) console.log("[geowarp] pixel height of source data:", in_pixel_height);
if (debug_level >= 1) console.log("[geowarp] pixel width of source data:", in_pixel_width);
in_geotransform ??= [in_xmin, in_pixel_width, 0, in_ymax, 0, -1 * in_pixel_height];
const { forward: in_img_pt_to_srs_pt, inverse: in_srs_pt_to_in_img_pt } = Geotransform(in_geotransform);
// convert point in output srs to image pixel coordinate in input image
const out_srs_pt_to_in_img_pt = same_srs ? in_srs_pt_to_in_img_pt : pt => in_srs_pt_to_in_img_pt(inv(pt));
const [out_xmin, out_ymin, out_xmax, out_ymax] = out_bbox;
if (debug_level >= 1) console.log("[geowarp] out_xmin:", out_xmin);
if (debug_level >= 1) console.log("[geowarp] out_ymin:", out_ymin);
if (debug_level >= 1) console.log("[geowarp] out_xmax:", out_xmax);
if (debug_level >= 1) console.log("[geowarp] out_ymax:", out_ymax);
out_pixel_height ??= (out_ymax - out_ymin) / out_height;
out_pixel_width ??= (out_xmax - out_xmin) / out_width;
if (debug_level >= 1) console.log("[geowarp] out_pixel_height:", out_pixel_height);
if (debug_level >= 1) console.log("[geowarp] out_pixel_width:", out_pixel_width);
const out_sample_height = out_pixel_height * y_scale;
const out_sample_width = out_pixel_width * x_scale;
if (debug_level >= 1) console.log("[geowarp] out_sample_height:", out_sample_height);
if (debug_level >= 1) console.log("[geowarp] out_sample_width:", out_sample_width);
const half_out_sample_height = out_sample_height / 2;
const half_out_sample_width = out_sample_width / 2;
// const out_geotransform = [out_xmin, out_pixel_width, 0, out_ymax, 0, -1 * out_pixel_height];
// const { forward: out_img_pt_to_srs_pt, inverse: out_srs_pt_to_img_pt } = Geotransform(out_geotransform);
const in_img_pt_to_out_srs_pt = same_srs ? in_img_pt_to_srs_pt : pt => fwd(in_img_pt_to_srs_pt(pt));
// const in_img_pt_to_out_img_pt = same_srs ? pt => out_srs_pt_to_img_pt(in_img_pt_to_srs_pts(pt)) : pt => out_srs_pt_to_img_pt(fwd(in_img_pt_to_srs_pt(pt)));
if (theoretical_min === undefined || theoretical_max === undefined) {
try {
const data_constructor = in_data[0].constructor.name;
if (debug_level >= 1) console.log("[geowarp] data_constructor:", data_constructor);
theoretical_min ??= getTheoreticalMin(data_constructor);
theoretical_max ??= getTheoreticalMax(data_constructor);
if (debug_level >= 1) console.log("[geowarp] theoretical_min:", theoretical_min);
if (debug_level >= 1) console.log("[geowarp] theoretical_max:", theoretical_max);
} catch (error) {
// we want to log an error if it happens
// even if we don't strictly need it to succeed
console.error(error);
}
}
if (![undefined, null, ""].includes(cutline_forward) && typeof cutline_forward !== "function") {
throw new Error("[geowarp] cutline_forward must be of type function not " + typeof cutline);
}
// if cutline isn't in the projection of the output, reproject it
let segments_by_row = new Array(out_height_in_samples).fill(0).map(() => []);
if (cutline && cutline_srs !== out_srs) {
if (!cutline_forward) {
// fallback to checking if we can use forward
if (in_srs === cutline_srs) cutline_forward = forward;
throw new Error("[geowarp] must specify cutline_forward when cutline_srs and out_srs differ");
}
let cutline_forward_turbocharged;
if (cutline_forward && cutline_bbox) {
cutline_forward_turbocharged = turbocharge({
bbox: cutline_bbox,
debug_level,
quiet: true,
reproject: cutline_forward,
threshold: [half_out_sample_width, half_out_sample_height]
})?.reproject;
}
cutline = reprojectGeoJSON(cutline, { reproject: cutline_forward_turbocharged || cutline_forward });
}
const out_column_max = out_width_in_samples - 1;
const full_width_row_segment = [0, out_column_max];
const full_width_row = [full_width_row_segment];
if (cutline) {
const intersections = dufour_peyton_intersection.calculate({
raster_bbox: out_bbox,
raster_height: out_height_in_samples,
raster_width: out_width_in_samples,
geometry: cutline
});
// we don't use per_row_segment because that can lead to overlap
intersections.rows.forEach((segs, irow) => {
segments_by_row[irow] = segs;
});
if (cutline_strategy === "inside") {
// flip the inside/outside segments
segments_by_row = segments_by_row.map(segs => {
if (segs.length === 0) {
return full_width_row;
} else {
return segflip({
segments: segs,
min: 0,
max: out_column_max,
debug: false
});
}
});
}
} else {
for (let row_index = 0; row_index < out_height_in_samples; row_index++) {
segments_by_row[row_index].push(full_width_row_segment);
}
}
const in_sizes = {
band: in_pixel_depth,
row: in_height,
column: in_width
};
const select = xdim.prepareSelect({ data: in_data, layout: in_layout, sizes: in_sizes });
const selectPixel = ({ row, column }) =>
read_bands.map(
band =>
select({
point: {
band,
row,
column
}
}).value
);
const hist = quickHistogram({ select: selectPixel, width: in_width, height: in_height }, [10, 10]);
const { hits, total } = hist.reduce(
(acc, [px, ct]) => {
acc.total += ct;
acc.hits += ct - 1; // subtracting 1 because the first instance of something won't use the cache
return acc;
},
{ hits: 0, total: 0 }
);
const predicted_cache_hit_rate = hits / total;
if (cache_process === undefined || cache_process === null) {
cache_process = predicted_cache_hit_rate >= 0.85;
}
if (typeof insert_pixel !== "function") {
let update;
// only works once update is defined later on
const update_pixel = ({ row, column, pixel }) => {
pixel.forEach((value, band) => {
update({
point: { band, row, column },
value
});
});
};
let insert_pixel_sync = ({ pixel, ...rest }) => {
try {
out_pixel_depth ??= pixel.length;
if (debug_level >= 1) console.log("[geowarp] out_pixel_depth:", out_pixel_depth);
const out_sizes = {
band: out_pixel_depth,
row: out_height,
column: out_width
};
if (debug_level >= 1) console.log("[geowarp] out_sizes:", out_sizes);
out_data ??= xdim.prepareData({
fill: out_no_data,
layout: out_layout,
sizes: out_sizes,
arrayTypes: out_array_types
}).data;
if (debug_level >= 1) console.log("[geowarp] out_data:", typeof out_data);
update = xdim.prepareUpdate({ data: out_data, layout: out_layout, sizes: out_sizes });
if (debug_level >= 1) console.log("[geowarp] prepared update function");
// replace self, so subsequent calls go directly to update_pixel
insert_pixel_sync = update_pixel;
update_pixel({ pixel, ...rest });
} catch (error) {
console.error("first call to insert_pixel_sync failed:", error);
}
};
insert_pixel = ({ pixel, ...rest }) => {
pending++;
quickResolve(pixel).then(px => {
insert_pixel_sync({ pixel: px, ...rest });
pending--;
});
};
}
if (typeof insert_sample !== "function") {
if (x_resolution === 1 && y_resolution === 1) {
// we call insert_pixel instead of setting insert_sample = insert_pixel
// because insert_pixel might have been hot swapped
insert_sample = params => insert_pixel(params);
} else {
insert_sample = ({ row, column, pixel, ...rest }) => {
const [xmin, ymin, xmax, ymax] = scalePixel([column, row], [x_scale, y_scale]);
for (let y = ymin; y < ymax; y++) {
for (let x = xmin; x < xmax; x++) {
insert_pixel({ row: y, column: x, pixel, ...rest });
}
}
};
}
}
row_end ??= out_height_in_samples;
if (debug_level >= 1) console.log("[geowarp] method:", method);
// see if can create direct pixel affine transformation
// skipping over spatial reference system
let inverse_pixel = ([c, r]) => {
const x = out_xmin + c * out_sample_width + half_out_sample_width;
const y = out_ymax - r * out_sample_height - half_out_sample_height;
const pt_out_srs = [x, y];
const pt_in_srs = same_srs ? pt_out_srs : inverse(pt_out_srs);
const pt_in_img = in_srs_pt_to_in_img_pt(pt_in_srs).map(n => Math.floor(n));
return pt_in_img;
};
if (turbo) {
const reproject = turbocharge({
bbox: [0, 0, out_width, out_height],
debug_level,
quiet: true,
reproject: inverse_pixel,
threshold: [0.5, 0.5]
})?.reproject;
if (reproject) inverse_pixel = pt => reproject(pt).map(n => Math.round(n));
}
let forward_turbocharged, inverse_turbocharged;
if (turbo) {
if (forward) {
out_bbox_in_srs ??= reprojectBoundingBox(out_bbox, inverse, { density: 100, nan_strategy: "skip" });
intersect_bbox_in_srs ??= intersect(in_bbox, out_bbox_in_srs);
forward_turbocharged = turbocharge({
bbox: intersect_bbox_in_srs,
debug_level,
quiet: true,
reproject: forward,
threshold: [half_out_sample_width, half_out_sample_height]
});
}
if (inverse) {
in_bbox_out_srs ??= reprojectBoundingBox(in_bbox, forward, { density: 100 });
intersect_bbox_out_srs ??= intersect(out_bbox, in_bbox_out_srs);
inverse_turbocharged = turbocharge({
bbox: intersect_bbox_out_srs,
debug_level,
quiet: true,
reproject: inverse,
threshold: [half_out_sample_width, half_out_sample_height]
});
}
}
if (debug_level >= 2) {
if (forward_turbocharged) console.log("[geowarp] turbocharged forward");
if (inverse_turbocharged) console.log("[geowarp] turbocharged inverse");
}
const fwd = forward_turbocharged?.reproject || forward;
const inv = inverse_turbocharged?.reproject || inverse;
// const [invCached, clearInvCache] = cacheFunction(inv);
let out_sample_height_in_srs, out_sample_width_in_srs, pixel_height_ratio, pixel_width_ratio;
if (method === "near-vectorize" || method === "nearest-vectorize") {
if (debug_level >= 2) console.log('[geowarp] choosing between "near" and "vectorize" for best speed');
out_bbox_in_srs ??= same_srs ? out_bbox : reprojectBoundingBox(out_bbox, inverse, { density: 100, nan_strategy: "skip" });
// average of how large each output pixel is in the input spatial reference system
out_sample_height_in_srs = (out_bbox_in_srs[3] - out_bbox_in_srs[1]) / out_height_in_samples;
out_sample_width_in_srs = (out_bbox_in_srs[2] - out_bbox_in_srs[0]) / out_width_in_samples;
pixel_height_ratio = out_sample_height_in_srs / in_pixel_height;
pixel_width_ratio = out_sample_width_in_srs / in_pixel_width;
if (debug_level >= 2) console.log("[geowarp] pixel_height_ratio:", pixel_height_ratio);
if (debug_level >= 2) console.log("[geowarp] pixel_width_ratio:", pixel_width_ratio);
if (pixel_height_ratio < 0.1 && pixel_width_ratio < 0.1) {
method = "vectorize";
if (debug_level >= 1) console.log('[geowarp] selected "vectorize" method as it is likely to be faster');
} else {
method = "near";
if (debug_level >= 1) console.log('[geowarp] selected "near" method as it is likely to be faster');
}
}
const should_skip =
skip_no_data_strategy === "any"
? px => px.some(n => isInvalid(n) || in_no_data.includes(n))
: skip_no_data_strategy === "all"
? px => px.every(n => isInvalid(n) || in_no_data.includes(n))
: () => false;
if (method === "vectorize") {
// const [cfwd, clear_forward_cache] = cacheFunction(fwd);
// reproject bounding box of output (e.g. a tile) into the spatial reference system of the input data
// setting nan_strategy to skip trims the box in case the output bbox extends over the bounds of the input projection
out_bbox_in_srs ??= same_srs ? out_bbox : reprojectBoundingBox(out_bbox, inverse, { density: 100, nan_strategy: "skip" });
let [left, bottom, right, top] = out_bbox_in_srs;
out_sample_height_in_srs ??= (top - bottom) / out_height_in_samples;
if (in_pixel_height < out_sample_height_in_srs) {
if (debug_level >= 1) {
console.warn(`[geowarp] normalized height of sample area of ${out_sample_height_in_srs} is larger than input pixel height of ${in_pixel_height}`);
}
}
out_sample_width_in_srs ??= (right - left) / out_width;
if (in_pixel_width < out_sample_width_in_srs) {
if (debug_level >= 1) {
console.warn(`[geowarp] normalized width of sample area of ${out_sample_width_in_srs} is larger than input pixel width of ${in_pixel_width}`);
}
}
// if have a cutline do additional clamping
const cutline_in_srs = cutline && reprojectGeoJSON(cutline, { reproject: inverse });
// in the future we might want to pull the function getBoundingBox into its own repo
const cutline_bbox_in_srs = cutline && getBoundingBox(cutline_in_srs);
if (!cutline || booleanIntersects(in_bbox, cutline_bbox_in_srs)) {
// update bounding box we sample from based on extent of cutline
[left, bottom, right, top] = cutline && cutline_strategy !== "inside" ? intersect(out_bbox_in_srs, cutline_bbox_in_srs) : out_bbox_in_srs;
if (debug_level >= 1) console.log("[geowarp] [left, bottom, right, top]:", [left, bottom, right, top]);
if ((left < in_xmax && bottom < in_ymax && right > in_xmin) || top < in_ymin) {
const out_bbox_in_input_image_coords = reprojectBoundingBox(out_bbox_in_srs, in_srs_pt_to_in_img_pt);
if (debug_level >= 1) console.log("[geowarp] out_bbox_in_input_image_coords:", out_bbox_in_input_image_coords);
// need to double check intersection in image space in case of rotation/skew
if (booleanIntersects(out_bbox_in_input_image_coords, [0, 0, in_width, in_height])) {
// snap to pixel array inidices
const [in_column_start, in_row_start, in_column_end, in_row_end] = out_bbox_in_input_image_coords.map(n => Math.floor(n));
const in_row_start_clamped = clamp(in_row_start, 0, in_height - 1);
const in_row_end_clamped = clamp(in_row_end, 0, in_height - 1);
const in_column_start_clamped = clamp(in_column_start, 0, in_width - 1);
const in_column_end_clamped = clamp(in_column_end, 0, in_width - 1);
for (let r = in_row_start_clamped; r <= in_row_end_clamped; r++) {
// if (clear_process_cache) clear_process_cache();
// clear_forward_cache(); // don't want cache to get too large, so just cache each row
for (let c = in_column_start_clamped; c <= in_column_end_clamped; c++) {
const raw_values = read_bands.map(band => select({ point: { band, row: r, column: c } }).value);
if (should_skip(raw_values)) continue;
const rect = polygon([c, r, c + 1, r + 1]);
// to-do: reproject to [I, J] (output image point) because
// intersection algorithm assumes an unskewed space
// we'll only have to do this if we want to support rotated/skewed output
const pixel_geometry_in_out_srs = reprojectGeoJSON(rect, { reproject: in_img_pt_to_out_srs_pt });
const intersect_options = {
debug: false,
raster_bbox: out_bbox,
raster_height: out_height_in_samples,
raster_width: out_width_in_samples,
geometry: pixel_geometry_in_out_srs
};
// apply band math expression, no-data mapping, and rounding when applicable
const pixel = process({ pixel: raw_values });
if (pixel !== null || insert_null_strategy === "insert") {
if (cutline) {
intersect_options.per_pixel = ({ row, column }) => {
if (segments_by_row[row].some(([start, end]) => column >= start && column <= end)) {
insert_sample({ raw: raw_values, pixel, row, column });
}
};
} else {
intersect_options.per_pixel = ({ row, column }) => {
insert_sample({ raw: raw_values, pixel, row, column });
};
}
dufour_peyton_intersection.calculate(intersect_options);
}
}
}
}
}
}
} else if (method === "near" || method === "nearest") {
const rmax = Math.min(row_end, out_height_in_samples);
for (let r = row_start; r < rmax; r++) {
// if (clear_process_cache) clear_process_cache();
const segments = segments_by_row[r];
for (let iseg = 0; iseg < segments.length; iseg++) {
const [cstart, cend] = segments[iseg];
for (let c = cstart; c <= cend; c++) {
const [x_in_raster_pixels, y_in_raster_pixels] = inverse_pixel([c, r]);
let raw_values = [];
if (x_in_raster_pixels < 0 || y_in_raster_pixels < 0 || x_in_raster_pixels >= in_width || y_in_raster_pixels >= in_height) {
// through reprojection, we can sometimes find ourselves just across the edge
raw_values = new Array(read_bands.length).fill(primary_in_no_data);
} else {
raw_values = selectPixel({
row: y_in_raster_pixels,
column: x_in_raster_pixels
});
}
if (should_skip(raw_values)) continue;
const pixel = process({ pixel: raw_values });
if (pixel !== null || insert_null_strategy === "insert") {
insert_sample({
row: r,
column: c,
pixel,
raw: raw_values,
x_in_raster_pixels,
y_in_raster_pixels
});
}
}
}
}
} else if (method === "bilinear") {
// see https://en.wikipedia.org/wiki/Bilinear_interpolation
const rmax = Math.min(row_end, out_height_in_samples);
let y = out_ymax + half_out_sample_height - row_start * out_sample_height;
for (let r = row_start; r < rmax; r++) {
// if (clear_process_cache) clear_process_cache();
y -= out_sample_height;
const segments = segments_by_row[r];
for (let iseg = 0; iseg < segments.length; iseg++) {
const [cstart, cend] = segments[iseg];
for (let c = cstart; c <= cend; c++) {
const x = out_xmin + c * out_sample_width + half_out_sample_width;
const pt_out_srs = [x, y];
const pt_in_srs = same_srs ? pt_out_srs : inv(pt_out_srs);
const [xInRasterPixels, yInRasterPixels] = in_srs_pt_to_in_img_pt(pt_in_srs);
const left = Math.floor(xInRasterPixels);
const right = Math.ceil(xInRasterPixels);
const top = Math.floor(yInRasterPixels);
const bottom = Math.ceil(yInRasterPixels);
// if xInRaster pixels is an integer,
// then leftWeight and rightWeight will equal zero
// that's not a problem though, because we ignore
// the weighting when values on each side are the same
const leftWeight = right - xInRasterPixels;
const rightWeight = xInRasterPixels - left;
const topWeight = top === bottom ? 0.5 : bottom - yInRasterPixels;
const bottomWeight = top === bottom ? 0.5 : yInRasterPixels - top;
const leftOutside = left < 0 || left >= in_width;
const rightOutside = right < 0 || right >= in_width;
const topOutside = top < 0 || top >= in_height;
const bottomOutside = bottom < 0 || bottom >= in_height;
const upperleftOutside = topOutside || leftOutside;
const upperRightOutside = topOutside || rightOutside;
const lowerleftOutside = bottomOutside || leftOutside;
const lowerRightOutside = bottomOutside || rightOutside;
const raw_values = new Array();
for (let i = 0; i < read_bands.length; i++) {
const read_band = read_bands[i];
const upperLeftValue = upperleftOutside ? primary_in_no_data : select({ point: { band: read_band, row: top, column: left } }).value;
const upperRightValue = upperRightOutside ? primary_in_no_data : select({ point: { band: read_band, row: top, column: right } }).value;
const lowerLeftValue = lowerleftOutside ? primary_in_no_data : select({ point: { band: read_band, row: bottom, column: left } }).value;
const lowerRightValue = lowerRightOutside ? primary_in_no_data : select({ point: { band: read_band, row: bottom, column: right } }).value;
let topValue;
const upperLeftInvalid = isInvalid(upperLeftValue) || in_no_data.includes(upperLeftValue);
const upperRightInvalid = isInvalid(upperRightValue) || in_no_data.includes(upperRightValue);
if (upperLeftInvalid && upperRightInvalid) {
// keep topValue undefined
} else if (upperLeftInvalid) {
topValue = upperRightValue;
} else if (upperRightInvalid) {
topValue = upperLeftValue;
} else if (upperLeftValue === upperRightValue) {
// because the upper-left and upper-right values are the same, no weighting is necessary
topValue = upperLeftValue;
} else {
topValue = leftWeight * upperLeftValue + rightWeight * upperRightValue;
}
let bottomValue;
const lowerLeftInvalid = isInvalid(lowerLeftValue) || in_no_data.includes(lowerLeftValue);
const lowerRightInvalid = isInvalid(lowerRightValue) || in_no_data.includes(lowerRightValue);
if (lowerLeftInvalid && lowerRightInvalid) {
// keep bottom value undefined
} else if (lowerLeftInvalid) {
bottomValue = lowerRightValue;
} else if (lowerRightInvalid) {
bottomValue = lowerLeftValue;
} else if (lowerLeftValue === lowerRightValue) {
// because the lower-left and lower-right values are the same, no weighting is necessary
bottomValue = lowerLeftValue;
} else {
bottomValue = leftWeight * lowerLeftValue + rightWeight * lowerRightValue;
}
let value;
if (topValue === undefined && bottomValue === undefined) {
value = primary_in_no_data;
} else if (topValue === undefined) {
value = bottomValue;
} else if (bottomValue === undefined) {
value = topValue;
} else {
value = bottomWeight * bottomValue + topWeight * topValue;
}
raw_values.push(value);
}
if (should_skip(raw_values)) continue;
const pixel = process({ pixel: raw_values });
if (pixel !== null || insert_null_strategy === "insert") {
insert_sample({ row: r, column: c, pixel, raw: raw_values });
}
}
}
}
} else {
// Q: why don't we pass no_data to the following statistical methods (e.g. fastMax)?
// A: we are already filtering out invalid and no-data values beforehand
let calc;
if (typeof method === "function") {
calc = values => method({ values });
} else if (method === "max") {
calc = values => fastMax(values, { theoretical_max });
} else if (method === "mean") {
calc = values => mean(values);
} else if (method === "median") {
calc = values => calcMedian(values);
} else if (method === "min") {
calc = values => fastMin(values, { theoretical_min });
} else if (method === "mode") {
calc = values => mode(values)[0];
} else if (method === "mode-max") {
calc = values => fastMax(mode(values));
} else if (method === "mode-mean") {
calc = values => mean(mode(values));
} else if (method === "mode-median") {
calc = values => calcMedian(mode(values));
} else if (method === "mode-min") {
calc = values => fastMin(mode(values));
} else {
throw new Error(`[geowarp] unknown method "${method}"`);
}
let top, left, bottom, right;
bottom = out_ymax - row_start * row_start;
const rmax = Math.min(row_end, out_height_in_samples);
for (let r = row_start; r < rmax; r++) {
// if (clear_process_cache) clear_process_cache();
top = bottom;
bottom = top - out_sample_height;
const segments = segments_by_row[r];
for (let iseg = 0; iseg < segments.length; iseg++) {
const [cstart, cend] = segments[iseg];
right = out_xmin + out_sample_width * cstart;
for (let c = cstart; c <= cend; c++) {
left = right;
right = left + out_sample_width;
// top, left, bottom, right is the sample area in the coordinate system of the output
// convert bbox in output srs to image px of input
// combing srs reprojection and srs-to-image mapping, ensures that bounding box corners
// are reprojected fully before calculating containing bbox
// (prevents drift in increasing bbox twice if image is warped)
let leftInRasterPixels, topInRasterPixels, rightInRasterPixels, bottomInRasterPixels;
try {
[leftInRasterPixels, topInRasterPixels, rightInRasterPixels, bottomInRasterPixels] = reprojectBoundingBox(
[left, bottom, right, top],
out_srs_pt_to_in_img_pt,
{ nan_strategy: "throw" }
);
} catch (error) {
// if only one pixel (or row of pixels) extends over the edge of the projection's bounds, we probably don't want to fail the whole thing
// an example would be warping the globe from 3857 to 4326
continue;
}
if (debug_level >= 4) console.log("[geowarp] leftInRasterPixels:", leftInRasterPixels);
if (debug_level >= 4) console.log("[geowarp] rightInRasterPixels:", rightInRasterPixels);
if (debug_level >= 4) console.log("[geowarp] topInRasterPixels:", topInRasterPixels);
if (debug_level >= 4) console.log("[geowarp] bottomInRasterPixels:", bottomInRasterPixels);
let leftSample = Math.round(leftInRasterPixels);
let rightSample = Math.round(rightInRasterPixels);
let topSample = Math.round(topInRasterPixels);
let bottomSample = Math.round(bottomInRasterPixels);
// if output pixel isn't large enough to sample an input pixel
// just pick input pixel at the center of the output pixel
if (leftSample === rightSample) {
const xCenterSample = (rightInRasterPixels + leftInRasterPixels) / 2;
leftSample = Math.floor(xCenterSample);
rightSample = leftSample + 1;
}
if (topSample === bottomSample) {
const yCenterSample = (topInRasterPixels + bottomInRasterPixels) / 2;
topSample = Math.floor(yCenterSample);
bottomSample = topSample + 1;
}
let raw_values = [];
if (leftSample >= in_width || rightSample < 0 || bottomSample < 0 || topSample >= in_height) {
raw_values = new Array(read_bands.length).fill(primary_in_no_data);
} else {
// clamp edges to prevent clipping outside bounds
leftSample = Math.max(0, leftSample);
rightSample = Math.min(rightSample, in_width);
topSample = Math.max(0, topSample);
bottomSample = Math.min(bottomSample, in_height);
for (let i = 0; i < read_bands.length; i++) {
const read_band = read_bands[i];
const { data: values } = xdim.clip({
data: in_data,
flat: true,
layout: in_layout,
sizes: in_sizes,
rect: {
band: [read_band, read_band],
row: [topSample, Math.max(topSample, bottomSample - 1)],
column: [leftSample, Math.max(leftSample, rightSample - 1)]
}
});
const valid_values = values.filter(v => typeof v === "number" && v === v && in_no_data.indexOf(v) === -1);
if (valid_values.length === 0) {
raw_values.push(primary_in_no_data);
} else {
raw_values.push(calc(valid_values));
}
}
}
if (should_skip(raw_values)) continue;
const pixel = process({ pixel: raw_values });
if (pixel !== null || insert_null_strategy === "insert") {
insert_sample({ row: r, column: c, pixel, raw: raw_values });
}
}
}
}
}
const generate_result = () => {
if (debug_level >= 1) console.log("[geowarp] took " + (performance.now() - start_time).toFixed(0) + "ms");
return {
data: out_data,
out_bands,
out_height,
out_layout,
out_pixel_depth,
out_pixel_height,
out_pixel_width,
out_sample_height,
out_sample_width,
out_width,
read_bands
};
};
if (pending > 0) {
// async return
return new Promise(resolve => {
const ms = 5; // re-check every 5 milliseconds
const intervalId = setInterval(() => {
if (pending === 0) {
clearInterval(intervalId);
resolve(generate_result());
}
}, ms);
});
} else {
// sync return
return generate_result();
}
};
if (typeof module === "object") {
module.exports = geowarp;
module.exports.default = geowarp;
}
if (typeof window === "object") window.geowarp = geowarp;
if (typeof self === "object") self.geowarp = geowarp;