geospatiale/files/png/png_delta_framework.js

//Initialise functions
{
	if (!global.GeoPNG)
		/**
		 * Analogous to a GeoTIFF file format, but in PNG form for easier editing. Single variable. Part of Geospatiale III.
		 *
		 * @namespace GeoPNG
		 */
		global.GeoPNG = {};
	
	/**
	 * Generates a delta series for a specific timeseries, used for later covariates.
	 * @alias GeoPNG.generateDeltaSeries
	 * 
	 * @param {string} arg0_output_folder_path
	 * @param {Object} [arg1_options]
	 *  @param {string} [arg1_options.input_format="int32"]
	 *  @param {function} [arg1_options.input_format_function] - (arg0_year:{@link number}). Returns {@link string} representing the file path for the given year.
	 *  @param {number[]} [arg1_options.years]
	 *
	 * @returns {Promise<void>}
	 */
	GeoPNG.generateDeltaSeries = async function (arg0_output_folder_path, arg1_options) {
		//Convert from parameters
		let output_folder_path = arg0_output_folder_path;
		let options = (arg1_options) ? arg1_options : {};
		
		//Initialise options
		if (!options.concurrency) options.concurrency = 8;
		if (!options.input_format) options.input_format = "int32";
		if (!options.prefix) options.prefix = "delta_";
		if (!options.years) options.years = [];
		
		//Declare local instance variables
		let delta_items = [];
		let out_dir = (typeof path !== "undefined") ? path.resolve(output_folder_path) : output_folder_path;
		let overwrite = (options.overwrite !== undefined) ? options.overwrite : true;
		
		//Ensure output directory exists
		if (typeof fs !== "undefined" && !fs.existsSync(out_dir))
			fs.mkdirSync(out_dir, { recursive: true });
		
		//Populate delta items for consecutive year pairs
		for (let i = 1; i < options.years.length; i++) {
			let current_year = options.years[i];
			let previous_year = options.years[i - 1];
			let output_file_path = `${output_folder_path}/${options.prefix}${current_year}.png`;
			
			if (overwrite || !fs.existsSync(output_file_path)) {
				delta_items.push({
					current_file_path: options.input_format_function(current_year),
					current_year: current_year,
					output_file_path: output_file_path,
					previous_file_path: options.input_format_function(previous_year),
					previous_year: previous_year,
					year_diff: current_year - previous_year
				});
			}
		}
		
		if (delta_items.length === 0) return [];
		
		//Return statement
		return GeoPNG.processTimeseriesParallel({
			concurrency: options.concurrency,
			items: delta_items,
			name: `Delta Timeseries (${options.prefix || "delta"})`,
			task_generator: (item) => ({
				format: "float32",
				format_1: options.input_format,
				format_2: options.input_format,
				input_path_1: item.current_file_path,
				input_path_2: item.previous_file_path,
				op: "slope",
				output_path: item.output_file_path,
				scalar: 1/item.year_diff,
				type: "raster_operation"
			}),
			handler: async (item) => {
				let current_raster = GeoPNG.loadNumberRasterImage(item.current_file_path, {
					format: options.input_format
				});
				let previous_raster = GeoPNG.loadNumberRasterImage(item.previous_file_path, {
					format: options.input_format
				});
				
				GeoPNG.saveNumberRasterImage({
					file_path: item.output_file_path,
					format: "float32",
					width: current_raster.width,
					height: current_raster.height,
					function: (local_index) => {
						let current_val = current_raster.data[local_index];
						let previous_val = previous_raster.data[local_index];
						return (current_val - previous_val)/item.year_diff;
					}
				});
			}
		});
	};
	
	/**
	 * Shatters a temporal series of rasters into a combined unique areal mask raster and records its historical lineage.
	 * @alias GeoPNG.shatter
	 *
	 * @param {string} arg0_input_prefix - The path prefix of the input PNG files (e.g. "path/to/gini_").
	 * @param {string} arg1_output_prefix - The path prefix for output files (saves to `${prefix}_masks.png` and `${prefix}_metadata.json`).
	 * @param {Object} [arg2_options] - Configuration options.
	 *  @param {number[]} [arg2_options.years=[]] - The temporal sequence of years to evaluate.
	 *
	 * @returns {Promise<Object|undefined>} Traced lineage mapping RGBA pixel representations to chronological value paths.
	 */
	GeoPNG.shatter = async function (arg0_input_prefix, arg1_output_prefix, arg2_options) {
		//Declare local instance variables
		let height = 0;
		let mask_data;
		let next_region_id = 1;
		let options = (arg2_options) ? arg2_options : {};
		let width = 0;
		let years = options.years || [];
		
		//region_history tracking: maps unique_id -> { parent: id, year: number, value: number }
		let region_history = new Map();
		
		//Iterate chronologically to split existing regions based on value changes over time
		for (let i = 0; i < years.length; i++) {
			let local_path = `${arg0_input_prefix}${years[i]}.png`;
			
			if (fs.existsSync(local_path)) {
				let local_raster = GeoPNG.loadNumberRasterImage(local_path, {
					format: "float32"
				});
				
				//Initialise mask_data dimensions using the first available valid raster
				if (!mask_data) {
					mask_data = new Int32Array(local_raster.data.length);
					height = local_raster.height;
					width = local_raster.width;
				}
				
				let transition_map = new Map();
				
				//Analyze pixel-by-pixel transitions to register unique paths
				for (let x = 0; x < local_raster.data.length; x++) {
					let current_pixel_id = mask_data[x];
					let current_value = local_raster.data[x];
					
					//Regions are unique if their parent ID combined with current value is unique
					let transition_key = `${current_pixel_id}|${current_value}`;
					
					if (!transition_map.has(transition_key)) {
						let new_id = next_region_id++;
						transition_map.set(transition_key, new_id);
						
						region_history.set(new_id, {
							parent: current_pixel_id,
							year: years[i],
							value: current_value
						});
					}
					mask_data[x] = transition_map.get(transition_key);
				}
				console.log(`- Processed ${years[i]}. Current unique region count: ${next_region_id - 1}`);
				await Blacktraffic.yield();
			}
		}
		
		if (mask_data) {
			//1. Track IDs that actually exist in the final spatial partition
			let final_ids = new Set();
			
			for (let i = 0; i < mask_data.length; i++) {
				final_ids.add(mask_data[i]);
			}
			
			//2. Reconstruct spatial-temporal history by traversing back up the lineage path
			let final_metadata = {};
			
			for (let local_id of final_ids) {
				let local_history = {};
				let local_pointer = local_id;
				
				while (local_pointer > 0) {
					let history_entry = region_history.get(local_pointer);
					
					if (history_entry) {
						local_history[history_entry.year] = history_entry.value;
						local_pointer = history_entry.parent;
					} else {
						local_pointer = 0;
					}
				}
				
				//Convert numerical ID to standard RGBA string representation for map rendering compatibility
				final_metadata[Colour.encodeNumberAsRGBA(Number(local_id)).join(",")] = local_history;
			}
			
			//3. Save the reconstructed areal mask PNG file
			GeoPNG.saveNumberRasterImage({
				file_path: `${arg1_output_prefix}_masks.png`,
				format: "int32",
				width,
				height,
				function: function (local_index) {
					//Return statement
					return mask_data[local_index];
				}
			});
			
			//4. Write output metadata to JSON
			fs.writeFileSync(`${arg1_output_prefix}_metadata.json`, JSON.stringify(final_metadata));
			console.log(`- Finished generating areal masks. Final unique regions: ${final_ids.size}.`);
			
			//Return statement
			return final_metadata;
		}
	};
}