diff --git a/doc/source/_workflows/outputs_accuracy/report.html b/doc/source/_workflows/outputs_accuracy/report.html index dd15816e3..01b8e28f1 100644 --- a/doc/source/_workflows/outputs_accuracy/report.html +++ b/doc/source/_workflows/outputs_accuracy/report.html @@ -2,11 +2,10 @@ Qualify elevation results

Accuracy assessment report — xDEM

-

xDEM version: 0.1.dev729

Date: 13/01/2026 15:02:39

Computing time: 23.89 seconds

Elevation datasets

-
- Image PNG - Image PNG -
+

xDEM version: 0.2.3.dev26

Date: 04/03/2026 15:54:49

Computing time: 29.13 seconds

Elevation inputs

+Image PNG +

Masked elevation data

+Image PNG

Information about inputs

@@ -16,6 +15,8 @@

Information about inputs

sampling_gridreference_elev
+

Preprocessed elevation data

+Image PNG

Coregistration user configuration

@@ -44,43 +45,16 @@

LZD outputs

Statistics

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DataMedianNMADValid count
Aligned elevation363.338928291.8012081309704
Difference before coreg2.7664073.8058301312020
Difference after coreg0.2544442.4039431309704

Elevation differences

-
- Image PNG - Image PNG -
+ + + + + +
DataAligned elevationDifference before coregDifference after coreg
Median316.3302.334-0.048
NMAD277.1033.4172.050
Valid count113360711452211133607
+

Elevation differences

+Image PNG

Differences histogram

-Image PNG +Image PNG - diff --git a/doc/source/_workflows/outputs_accuracy/report.pdf b/doc/source/_workflows/outputs_accuracy/report.pdf index 80355be2b..cd4153746 100644 Binary files a/doc/source/_workflows/outputs_accuracy/report.pdf and b/doc/source/_workflows/outputs_accuracy/report.pdf differ diff --git a/doc/source/_workflows/outputs_topo/report.html b/doc/source/_workflows/outputs_topo/report.html index c06e46b10..32ba165b4 100644 --- a/doc/source/_workflows/outputs_topo/report.html +++ b/doc/source/_workflows/outputs_topo/report.html @@ -2,10 +2,10 @@ Topographic summary results

Topography summary report — xDEM

-

xDEM version: 0.1.dev729

Date: 13/01/2026 15:02:58

Computing time: 9.71 seconds

Elevation data

-Image PNG +

xDEM version: 0.2.3.dev26

Date: 04/03/2026 15:55:14

Computing time: 16.52 seconds

Elevation input

+Image PNG

Masked elevation data

-Image PNG +Image PNG

Information about inputs

@@ -34,24 +34,15 @@

Elevation information

-

Global statistics

+

Statistics

- - - -
InformationValue
median360.65
nmad290.22
validcount1312020
-
-
-

Mask statistics

- - - - - + + +
InformationValue
median318.29
nmad278.1
validcount1312020
Median318.290
NMAD278.100
Valid count1145221

Terrain attributes

-Image PNG +Image PNG diff --git a/doc/source/_workflows/outputs_topo/report.pdf b/doc/source/_workflows/outputs_topo/report.pdf index c08bc9175..033938e69 100644 Binary files a/doc/source/_workflows/outputs_topo/report.pdf and b/doc/source/_workflows/outputs_topo/report.pdf differ diff --git a/doc/source/cli_accuracy.md b/doc/source/cli_accuracy.md index 3e260489d..8865ec77e 100644 --- a/doc/source/cli_accuracy.md +++ b/doc/source/cli_accuracy.md @@ -116,13 +116,13 @@ These categories and detailed parameter values are further detailed below: **Required:** Yes -Elevation input information, split between reference and to-be-aligned elevation data. +Inputs information, split between reference and to-be-aligned elevation data. :::::{tab-set} -::::{tab-item} `reference_elev` +::::{tab-item} `reference_elev` / `to_be_aligned_elev` -:::{table} Inputs parameters for `reference_elev` -:widths: 20, 35, 17, 18, 10 +:::{table} Inputs parameters for `reference_elev` and `to_be_aligned_elev` +:widths: 20, 40, 20, 10, 10 | Name | Description | Type | Default | Required | |-----------------------|------------------------------------------|------------|---------|----------| @@ -146,40 +146,13 @@ Please refer to {ref}`data-example` to have more information. :::: -::::{tab-item} `to_be_aligned_elev` - -:::{table} Inputs parameters for `to_be_aligned_elev` -:widths: 20, 35, 17, 18, 10 - -| Name | Description | Type | Default | Required | -|------------------------|------------------------------------------|------------|----------|----------| -| `path_to_elev` | Path to to-be-aligned elevation | str | | Yes | -| `force_source_nodata` | No data elevation | int | | No | -| `path_to_mask` | Path to mask associated to the elevation | str | | No | -| `from_vcrs` | Original vcrs | int, str | `null` | No | -| `to_vcrs` | Destination vcrs | int, str | `null` | No | -| `downsample` | Downsampling elevation factor >= 1 | int, float | 1 | No | -::: - -:::{note} -For transforming between vertical CRS with ``from_vcrs``/``to_vcrs`` please refer to {ref}`vertical-ref`. -The ``downsample`` parameter allows the user to resample the elevation by a round factor. -The default value of 1 means no downsampling. - -And, if you want to test the CLI with xDEM example data, they can also refer to data alias. -Please refer to {ref}`data-example` to have more information. -::: - - - -:::: ::::{tab-item} `sampling_grid` Raster to match for reprojection. :::{table} Values for ``sampling_grid`` -:widths: 30, 40, 10, 10, 10 +:widths: 30, 60, 10 | Value | Description | Default | |----------------------|-------------------------------------------------------------------------|---------| diff --git a/tests/test_workflows/conftest.py b/tests/test_workflows/conftest.py index b39632a03..badbc1a69 100644 --- a/tests/test_workflows/conftest.py +++ b/tests/test_workflows/conftest.py @@ -25,7 +25,7 @@ import xdem from xdem.workflows import Accuracy -from xdem.workflows.schemas import TERRAIN_ATTRIBUTES_DEFAULT +from xdem.workflows.schemas import MIN_STATS, TERRAIN_ATTRIBUTES_DEFAULT @pytest.fixture() @@ -114,23 +114,7 @@ def pipeline_topo(): "downsample": 1, } }, - "statistics": [ - "mean", - "median", - "max", - "min", - "sum", - "sumofsquares", - "90thpercentile", - "le90", - "nmad", - "rmse", - "std", - "standarddeviation", - "validcount", - "totalcount", - "percentagevalidpoints", - ], + "statistics": MIN_STATS, "terrain_attributes": TERRAIN_ATTRIBUTES_DEFAULT, "outputs": {"path": "outputs", "level": 1}, } diff --git a/tests/test_workflows/test_accuracy.py b/tests/test_workflows/test_accuracy.py index b3d84f963..3decd93fd 100644 --- a/tests/test_workflows/test_accuracy.py +++ b/tests/test_workflows/test_accuracy.py @@ -31,7 +31,8 @@ import xdem from xdem.workflows import Accuracy -from xdem.workflows.workflows import Workflows +from xdem.workflows.schemas import MIN_STATS +from xdem.workflows.workflows import _ALIAS, Workflows pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") pytest.importorskip("cerberus") @@ -45,13 +46,11 @@ def test_init_diff_analysis(get_accuracy_object_with_run, tmp_path): assert isinstance(workflows, Workflows) assert isinstance(workflows, Accuracy) - assert Path(tmp_path / "plots").joinpath("reference_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("to_be_aligned_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("reference_elev_map.png").exists() + assert Path(tmp_path / "plots").joinpath("inputs.png").exists() dem = xdem.DEM(xdem.examples.get_path_test("longyearbyen_tba_dem")) mask = gu.Vector(xdem.examples.get_path_test("longyearbyen_glacier_outlines")) inlier_mask = ~mask.create_mask(dem) - assert workflows.inlier_mask == inlier_mask + assert workflows.to_be_aligned_elev.get_mask() == inlier_mask def test__get_reference_elevation(get_accuracy_inputs_config, tmp_path, caplog, assert_and_allow_log): @@ -64,7 +63,7 @@ def test__get_reference_elevation(get_accuracy_inputs_config, tmp_path, caplog, workflows = Accuracy(user_config) workflows._load_data() - with pytest.raises(NotImplementedError, match="This is not implemented, add a reference DEM"): + with pytest.raises(NotImplementedError, match="This is not implemented, add a reference elevation"): workflows._get_reference_elevation() user_config = get_accuracy_inputs_config @@ -72,7 +71,7 @@ def test__get_reference_elevation(get_accuracy_inputs_config, tmp_path, caplog, user_config["inputs"]["reference_elev"] = None with caplog.at_level(logging.WARNING): - with pytest.raises(NotImplementedError, match="This is not implemented, add a reference DEM"): + with pytest.raises(NotImplementedError, match="This is not implemented, add a reference elevation"): workflows = Accuracy(user_config) workflows._load_data() @@ -87,56 +86,30 @@ def test__compute_coregistration(): """ -def test__get_stats(get_accuracy_inputs_config, tmp_path): +@pytest.mark.parametrize( + "stats_name, res", + [ + [MIN_STATS, [_ALIAS.get(k) for k in MIN_STATS]], + [list(_ALIAS.keys()), [_ALIAS.get(k) for k in _ALIAS.keys()]], + [["std"], ["Standard deviation"]], + [["standarddeviation"], ["Standard deviation"]], + [["std", "standarddeviation"], ["Standard deviation"]], + ], +) +def test__get_stats(get_accuracy_inputs_config, tmp_path, stats_name, res): """ Test _get_stats function """ user_config = get_accuracy_inputs_config user_config["outputs"] = {"path": str(tmp_path)} + user_config["statistics"] = stats_name workflows = Accuracy(user_config) dem = xdem.DEM(xdem.examples.get_path_test("longyearbyen_tba_dem")) - stats_gt = dem.get_stats( - [ - "mean", - "median", - "max", - "min", - "sum", - "sumofsquares", - "90thpercentile", - "le90", - "nmad", - "rmse", - "std", - "standarddeviation", - "validcount", - "totalcount", - "percentagevalidpoints", - ] - ) + stats_gt = dem.get_stats(stats_name) - # Aliases for nicer CSV headers - aliases = { - "mean": "Mean", - "median": "Median", - "max": "Maximum", - "min": "Minimum", - "sum": "Sum", - "sumofsquares": "Sum of squares", - "90thpercentile": "90th percentile", - "le90": "LE90", - "nmad": "NMAD", - "rmse": "RMSE", - "std": "STD", - "standarddeviation": "Standard deviation", - "validcount": "Valid count", - "totalcount": "Total count", - "percentagevalidpoints": "Percentage valid points", - } - - stats_gt = {aliases.get(k, k): v for k, v in stats_gt.items()} - assert workflows._get_stats(dem) == stats_gt + assert list(set(workflows._get_stats(dem).keys())) == list(set(res)) # type: ignore + assert workflows._get_stats(dem) == {_ALIAS.get(k, k): v for k, v in stats_gt.items()} def test__compute_histogram(get_accuracy_object_with_run, tmp_path): @@ -165,12 +138,10 @@ def test_run(get_accuracy_inputs_config, tmp_path, level): assert Path(tmp_path / "tables").joinpath("aligned_elev_stats.csv").exists() - assert Path(tmp_path / "plots").joinpath("diff_elev_after_coreg_map.png").exists() - assert Path(tmp_path / "plots").joinpath("diff_elev_before_coreg_map.png").exists() + assert Path(tmp_path / "plots").joinpath("diff_elev_diff_coreg_map.png").exists() assert Path(tmp_path / "plots").joinpath("elev_diff_histo.png").exists() assert Path(tmp_path / "plots").joinpath("masked_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("reference_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("to_be_aligned_elev_map.png").exists() + assert Path(tmp_path / "plots").joinpath("inputs.png").exists() assert Path(tmp_path / "rasters").joinpath("aligned_elev.tif").exists() @@ -228,14 +199,13 @@ def test_run_without_coreg(get_accuracy_inputs_config, tmp_path, level): workflows = Accuracy(user_config) workflows.run() - assert Path(tmp_path / "tables").joinpath("diff_elev_stats.csv").exists() + assert Path(tmp_path / "tables").joinpath("diff_elev_without_coreg_stats.csv").exists() - assert Path(tmp_path / "plots").joinpath("diff_elev.png").exists() - assert not Path(tmp_path / "plots").joinpath("diff_elev_before_coreg.png").exists() + assert Path(tmp_path / "plots").joinpath("diff_elev_without_coreg_map.png").exists() + assert not Path(tmp_path / "plots").joinpath("diff_elev_diff_coreg_map.png").exists() assert not Path(tmp_path / "plots").joinpath("elev_diff_histo.png").exists() assert Path(tmp_path / "plots").joinpath("masked_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("reference_elev_map.png").exists() - assert Path(tmp_path / "plots").joinpath("to_be_aligned_elev_map.png").exists() + assert Path(tmp_path / "plots").joinpath("inputs.png").exists() assert not Path(tmp_path / "rasters").joinpath("aligned_elev.tif").exists() @@ -245,12 +215,12 @@ def test_run_without_coreg(get_accuracy_inputs_config, tmp_path, level): assert Path(tmp_path).joinpath("used_config.yaml").exists() csv_files = [ - "diff_elev_stats.csv", + "diff_elev_without_coreg_stats.csv", "reference_elev_stats.csv", "to_be_aligned_elev_stats.csv", ] - raster_files = ["diff_elev.tif"] + raster_files = ["diff_elev_without_coreg_map.tif"] if level == 1: for file in csv_files: @@ -346,11 +316,14 @@ def test_prepare_datas(get_accuracy_inputs_config, tmp_path, config): # Save path before crop(s) original_ref_path = user_config["inputs"]["reference_elev"]["path_to_elev"] original_tba_path = user_config["inputs"]["to_be_aligned_elev"]["path_to_elev"] + user_config["inputs"]["reference_elev"]["path_to_mask"] = None + user_config["inputs"]["to_be_aligned_elev"]["path_to_mask"] = None # Update user_config user_config["outputs"] = {"path": str(tmp_path), "level": 2} user_config["coregistration"] = {"process": False} user_config["inputs"]["sampling_grid"] = sampling_grid + user_config["inputs"]["reference_elev"]["path_to_mask"] = None user_config["inputs"]["to_be_aligned_elev"]["path_to_mask"] = None # Init crops possible values @@ -432,17 +405,75 @@ def test_create_html(tmp_path, get_accuracy_object_with_run): assert Path(tmp_path).joinpath("report.html").exists() -def test_mask_init(tmp_path, get_accuracy_inputs_config): +@pytest.mark.parametrize( + "masked", + [ + [True, True], + [False, True], + [True, False], + [False, False], + ], +) +def test_mask(tmp_path, get_accuracy_inputs_config, masked): """ - Test mask initialization + Test mask initialization and correg """ user_config = get_accuracy_inputs_config - user_config["outputs"] = {"path": str(tmp_path)} - del user_config["inputs"]["reference_elev"]["path_to_mask"] + masked_ref, masked_tba = masked + user_config["outputs"] = {"path": str(tmp_path), "level": 2} + print(user_config) + ref_dem_path = xdem.examples.get_path_test("longyearbyen_ref_dem") + tba_dem_path = xdem.examples.get_path_test("longyearbyen_tba_dem") + mask_ref_dem_path = xdem.examples.get_path_test("longyearbyen_glacier_outlines") + mask_tba_dem_path = xdem.examples.get_path_test("longyearbyen_glacier_outlines_2010") + + # Create 1/2 mask (up) for ref and 1/2 mask (bottom) for tba + ref_dem = xdem.DEM(ref_dem_path) + ref_dem.load() + tba_dem = xdem.DEM(tba_dem_path) + tba_dem.load() + ref_mask = gu.Vector(mask_ref_dem_path) + tba_mask = gu.Vector(mask_tba_dem_path) + + user_config["inputs"]["reference_elev"]["path_to_elev"] = ref_dem_path + if masked_ref: + inlier_mask = ~ref_mask.create_mask(ref_dem) + inlier_mask_reproject = inlier_mask.reproject(ref_dem).crop(ref_dem) + ref_dem.set_mask(~inlier_mask_reproject) + user_config["inputs"]["reference_elev"]["path_to_mask"] = mask_ref_dem_path + else: + user_config["inputs"]["reference_elev"]["path_to_mask"] = None + + user_config["inputs"]["to_be_aligned_elev"]["path_to_elev"] = tba_dem_path + if masked_tba: + inlier_mask = ~tba_mask.create_mask(tba_dem) + inlier_mask_reproject = inlier_mask.reproject(tba_dem).crop(tba_dem) + tba_dem.set_mask(~inlier_mask_reproject) + user_config["inputs"]["to_be_aligned_elev"]["path_to_mask"] = mask_tba_dem_path + else: + user_config["inputs"]["to_be_aligned_elev"]["path_to_mask"] = None + + # Apply to config dict workflows = Accuracy(user_config) - workflows._load_data() - dem = xdem.DEM(xdem.examples.get_path_test("longyearbyen_tba_dem")) - mask = gu.Vector(xdem.examples.get_path_test("longyearbyen_glacier_outlines")) - inlier_mask = ~mask.create_mask(dem) - assert workflows.inlier_mask == inlier_mask - assert Path(tmp_path / "plots").joinpath("masked_elev_map.png").exists() + workflows.run() + + # Verify 1/2 mask application for ref data + stats_ref = pd.read_csv(Path(tmp_path / "tables" / "reference_elev_stats.csv").as_posix()) + assert stats_ref["Valid count"].values[0] == ref_dem.get_stats("Valid count") + + # Count 1/2 mask application for tba data + stats_tba = pd.read_csv(Path(tmp_path / "tables" / "to_be_aligned_elev_stats.csv").as_posix()) + assert stats_tba["Valid count"].values[0] == tba_dem.get_stats("Valid count") + + stats_tba_aligned = pd.read_csv(Path(tmp_path / "tables" / "aligned_elev_stats.csv").as_posix()) + aligned_tba = tba_dem.coregister_3d(ref_dem, xdem.coreg.LZD(subsample=10000), random_state=42) + assert stats_tba_aligned["Valid count"].values[0] == aligned_tba.get_stats("Valid count") + + # Count full mask on diff elev data + stats_before = pd.read_csv(Path(tmp_path / "tables" / "diff_elev_before_coreg_stats.csv").as_posix()) + stats_after = pd.read_csv(Path(tmp_path / "tables" / "diff_elev_after_coreg_stats.csv").as_posix()) + + diff_before = tba_dem - ref_dem + assert stats_before["Valid count"].values[0] == diff_before.get_stats("Valid count") + diff_after = aligned_tba.reproject(ref_dem) - ref_dem + assert stats_after["Valid count"].values[0] == diff_after.get_stats("Valid count") diff --git a/tests/test_workflows/test_topo.py b/tests/test_workflows/test_topo.py index 7bfcdcc55..70fb2d73c 100644 --- a/tests/test_workflows/test_topo.py +++ b/tests/test_workflows/test_topo.py @@ -28,8 +28,8 @@ import xdem from xdem.workflows import Topo -from xdem.workflows.schemas import STATS_METHODS -from xdem.workflows.workflows import Workflows +from xdem.workflows.schemas import MIN_STATS +from xdem.workflows.workflows import _ALIAS, Workflows pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") @@ -133,9 +133,7 @@ def test_run(get_topo_inputs_config, tmp_path): user_config["outputs"] = {"path": str(tmp_path)} workflows = Topo(user_config) workflows.run() - assert Path(tmp_path / "tables").joinpath("stats_elev_stats.csv").exists() - assert Path(tmp_path / "tables").joinpath("stats_elev_mask_stats.csv").exists() assert Path(tmp_path).joinpath("report.html").exists() # Check subdictionaries content, except exact stats values in case test data/algorithms slightly changes, # and as those are already tested separately @@ -172,7 +170,27 @@ def test_run(get_topo_inputs_config, tmp_path): ) # 3/ Statistics names - assert workflows.dico_to_show[2][0] == "Global statistics" - assert list(workflows.dico_to_show[2][1].keys()) == STATS_METHODS - assert workflows.dico_to_show[3][0] == "Mask statistics" - assert list(workflows.dico_to_show[3][1].keys()) == STATS_METHODS + assert workflows.dico_to_show[2][0] == "Statistics" + + assert list(workflows.dico_to_show[2][1].keys()) == [_ALIAS.get(k) for k in MIN_STATS] + + +@pytest.mark.parametrize( + "stats_name, res", + [ + [MIN_STATS, [_ALIAS.get(k) for k in MIN_STATS]], + [list(_ALIAS.keys()), [_ALIAS.get(k) for k in _ALIAS.keys()]], + [["std"], ["Standard deviation"]], + [["standarddeviation"], ["Standard deviation"]], + [["std", "standarddeviation"], ["Standard deviation"]], + ], +) +def test_stats(get_topo_inputs_config, tmp_path, stats_name, res): + + user_config = get_topo_inputs_config + user_config["outputs"] = {"path": str(tmp_path)} + user_config["statistics"] = stats_name + + workflows = Topo(user_config) + workflows.run() + assert list(set(workflows.dico_to_show[2][1].keys())) == list(set(res)) diff --git a/xdem/workflows/accuracy.py b/xdem/workflows/accuracy.py index 58eab3a20..3d2e6ac9a 100644 --- a/xdem/workflows/accuracy.py +++ b/xdem/workflows/accuracy.py @@ -35,7 +35,7 @@ import xdem from xdem._misc import import_optional from xdem.workflows.schemas import ACCURACY_SCHEMA -from xdem.workflows.workflows import Workflows +from xdem.workflows.workflows import _ALIAS, Workflows class Accuracy(Workflows): @@ -74,52 +74,57 @@ def __init__(self, config_dem: str | Dict[str, Any], output: str | None = None) self.config = self.remove_none(self.config) # type: ignore - def _load_data(self) -> None: - """Load data.""" + def _load_data(self) -> tuple[float, float]: + """ + Load data - self.to_be_aligned_elev, tba_mask, tba_path_mask = self.load_dem(self.config["inputs"]["to_be_aligned_elev"]) + :return vmin, vmax: to plot elevation data with the same scale + """ self.reference_elev, ref_mask, ref_mask_path = self.load_dem(self.config["inputs"].get("reference_elev", None)) + self.to_be_aligned_elev, tba_mask, tba_path_mask = self.load_dem(self.config["inputs"]["to_be_aligned_elev"]) if self.reference_elev is None: self.reference_elev = self._get_reference_elevation() + + vmin = float(min(np.nanpercentile(self.reference_elev, q=5), np.nanpercentile(self.to_be_aligned_elev, q=5))) + vmax = float(max(np.nanpercentile(self.reference_elev, q=95), np.nanpercentile(self.to_be_aligned_elev, q=95))) + self.generate_plot( - self.reference_elev, - title="Reference DEM", - filename="reference_elev_map", - cmap="terrain", - cbar_title="Elevation (m)", - ) - self.generate_plot( - self.to_be_aligned_elev, - title="To-be-aligned DEM", - filename="to_be_aligned_elev_map", - cmap="terrain", - cbar_title="Elevation (m)", + dem=self.reference_elev, + title="Reference elevation", + filename="inputs", + dem_right=self.to_be_aligned_elev, + title_dem_right="To-be-aligned elevation", + vmin=vmin, + vmax=vmax, + cbar_title=f"Elevation ({self.reference_elev.crs.linear_units})", ) + if ref_mask is not None or tba_mask is not None: + if ref_mask is not None: + inlier_mask_crop = ref_mask.reproject(self.reference_elev).crop(self.reference_elev) + self.reference_elev.set_mask(~inlier_mask_crop) + if tba_mask is not None: + inlier_mask_crop = tba_mask.reproject(self.to_be_aligned_elev).crop(self.to_be_aligned_elev) + self.to_be_aligned_elev.set_mask(~inlier_mask_crop) - self.inlier_mask = None - if ref_mask is not None and tba_mask is not None: - self.inlier_mask = tba_mask - path_mask = tba_path_mask - else: - self.inlier_mask = ref_mask or tba_mask - path_mask = ref_mask_path or tba_path_mask - - if self.inlier_mask is not None: self.generate_plot( - self.to_be_aligned_elev, - title="Masked (inlier) terrain", + self.reference_elev, + title="Masked terrain for reference elevation", filename="masked_elev_map", - mask_path=path_mask, - cmap="terrain", - cbar_title="Elevation (m)", + dem_right=self.to_be_aligned_elev, + title_dem_right="Masked terrain for to-be-aligned elevation", + vmin=vmin, + vmax=vmax, + cbar_title=f"Elevation ({self.reference_elev.crs.linear_units})", ) + return vmin, vmax + def _get_reference_elevation(self) -> float: """ Get reference elevation. """ - raise NotImplementedError("This is not implemented, add a reference DEM") + raise NotImplementedError("This is not implemented, add a reference elevation") def _compute_coregistration(self) -> RasterType: """ @@ -142,16 +147,14 @@ def _compute_coregistration(self) -> RasterType: config_coreg = self.config["coregistration"].get(step) if config_coreg: method_name = config_coreg.get("method") + print(method_name) coreg_extra = config_coreg.get("extra_information", {}) coreg_fun = partial(method_map[method_name], **coreg_extra) coreg_functions.append(coreg_fun()) - my_coreg = sum(coreg_functions[1:], coreg_functions[0]) if len(coreg_functions) > 1 else coreg_functions[0] # Coregister - aligned_elev = self.to_be_aligned_elev.coregister_3d( - self.reference_elev, my_coreg, self.inlier_mask, random_state=42 - ) + aligned_elev = self.to_be_aligned_elev.coregister_3d(self.reference_elev, my_coreg, random_state=42) aligned_elev.to_file(self.outputs_folder / "rasters" / "aligned_elev.tif") self.dico_to_show.append(("Coregistration user configuration", self.config["coregistration"])) @@ -169,9 +172,12 @@ def _compute_coregistration(self) -> RasterType: return aligned_elev - def _prepare_datas(self) -> None: + def _prepare_datas(self, vmin: float, vmax: float) -> None: """ Compute reprojection. + + :param vmin: to plot elevation data with the same scale + :param vmax: to plot elevation data with the same scale """ sampling_source = self.config["inputs"]["sampling_grid"] @@ -195,23 +201,26 @@ def _prepare_datas(self) -> None: # Intersection logging.info("Computing intersection") coord_intersection = self.reference_elev.intersection(self.to_be_aligned_elev) + if sampling_source == "reference_elev": - self.reference_elev = self.reference_elev.crop(coord_intersection) + self.to_be_aligned_elev = self.to_be_aligned_elev.crop(coord_intersection) self.generate_plot( self.to_be_aligned_elev, - title="Cropped reference DEM", - filename="cropped_reference_elev_map", - cmap="terrain", - cbar_title="Elevation (m)", + title="Preprocessed to-be-aligned elevation", + filename="preprocessed_to_be_aligned_elev_map", + vmin=vmin, + vmax=vmax, + cbar_title=f"Elevation ({self.to_be_aligned_elev.crs.linear_units})", ) else: - self.to_be_aligned_elev = self.to_be_aligned_elev.crop(coord_intersection) + self.reference_elev = self.reference_elev.crop(coord_intersection) self.generate_plot( - self.to_be_aligned_elev, - title="Cropped to-be-aligned DEM", - filename="cropped_to_be_aligned_elev_map", - cmap="terrain", - cbar_title="Elevation (m)", + self.reference_elev, + title="Preprocessed reference elevation", + filename="preprocessed_reference_elev_map", + vmin=vmin, + vmax=vmax, + cbar_title=f"Elevation ({self.reference_elev.crs.linear_units})", ) if self.level > 1: @@ -228,30 +237,12 @@ def _get_stats(self, dem: RasterType, name_of_data: str = "") -> floating[Any] | # Compute user statistics dict_stats_aliased = {} list_to_compute = self.config["statistics"] + print("list_to_compute", list_to_compute) + if list_to_compute is not None: logging.info(f"Computing statistics on {name_of_data}: {list_to_compute}") dict_stats = dem.get_stats(list_to_compute) - - # Aliases for nicer CSV headers - aliases = { - "mean": "Mean", - "median": "Median", - "max": "Maximum", - "min": "Minimum", - "sum": "Sum", - "sumofsquares": "Sum of squares", - "90thpercentile": "90th percentile", - "le90": "LE90", - "nmad": "NMAD", - "rmse": "RMSE", - "std": "STD", - "standarddeviation": "Standard deviation", - "validcount": "Valid count", - "totalcount": "Total count", - "percentagevalidpoints": "Percentage valid points", - } - - dict_stats_aliased = {aliases.get(k, k): v for k, v in dict_stats.items()} + dict_stats_aliased = {_ALIAS.get(k, k): v for k, v in dict_stats.items()} return dict_stats_aliased @@ -264,7 +255,18 @@ def _compute_histogram(self) -> None: import matplotlib.pyplot as plt logging.info("Computing histogram on altitude difference") - plt.figure(figsize=(12, 6)) + + # Force figsize with the same size as generate_plot function + plt.figure(figsize=[6.4, 2.34]) + size_font = 6 + plt.rc("font", size=size_font) + plt.rc("axes", titlesize=size_font) + plt.rc("axes", labelsize=size_font) + plt.rc("xtick", labelsize=size_font) + plt.rc("ytick", labelsize=size_font) + plt.rc("legend", fontsize=size_font) + plt.rc("figure", titlesize=size_font) + bins = np.linspace(self.stats_before["min"], self.stats_before["max"], 300) plt.xlim((-4 * np.std(self.diff_before), 4 * np.std(self.diff_before))) plt.hist(self.diff_before.data.flatten(), bins=bins, color="g", alpha=0.5, label="Before coregistration") @@ -289,11 +291,11 @@ def _compute_histogram(self) -> None: va="center", ) plt.title("Histogram of elevation differences\nbefore and after coregistration") - plt.xlabel("Elevation differences (m)") + plt.xlabel(f"Elevation differences ({self.reference_elev.crs.linear_units})") plt.ylabel("Count") plt.legend() plt.grid(False) - plt.savefig(self.outputs_folder / "plots" / "elev_diff_histo.png") + plt.savefig(self.outputs_folder / "plots" / "elev_diff_histo.png", dpi=300, bbox_inches="tight") plt.close() def run(self) -> None: @@ -305,11 +307,11 @@ def run(self) -> None: t0 = time.time() - self._load_data() + vmin, vmax = self._load_data() # Reprojection step if "sampling_grid" in self.config["inputs"]: - self._prepare_datas() + self._prepare_datas(vmin, vmax) if self.compute_coreg: # Coregistration step @@ -320,39 +322,50 @@ def run(self) -> None: output_grid = self.config["outputs"]["output_grid"] ref_elev = self.reference_elev if output_grid == "reference_elev" else self.to_be_aligned_elev - - vmin = vmax = None + stats_keys = ["min", "max", "nmad", "median"] if self.compute_coreg: - diff_pairs = [("before", self.to_be_aligned_elev), ("after", aligned_elev.reproject(ref_elev))] - else: - diff_pairs = [("", self.to_be_aligned_elev)] - - for label, dem in diff_pairs: - diff = dem - ref_elev - stats_keys = ["min", "max", "nmad", "median"] - stats = diff.get_stats(stats_keys) - - if label == "before": - self.diff_before, self.stats_before = diff, stats - vmin, vmax = -(stats["median"] + 3 * stats["nmad"]), stats["median"] + 3 * stats["nmad"] - elif label == "after": - self.diff_after, self.stats_after = diff, stats - else: - self.diff = diff - vmin, vmax = -(stats["median"] + 3 * stats["nmad"]), (stats["median"] + 3 * stats["nmad"]) - suffix = f"_elev_{label}_coreg_map" if label else "_elev" + self.diff_before = self.to_be_aligned_elev - ref_elev + self.stats_before = self.diff_before.get_stats(stats_keys) + + self.diff_after = aligned_elev.reproject(ref_elev) - ref_elev + self.stats_after = self.diff_after.get_stats(stats_keys) + + vmin_diff = min( + -(self.stats_before["median"] + 3 * self.stats_before["nmad"]), + -(self.stats_after["median"] + 3 * self.stats_after["nmad"]), + ) + vmax_diff = max( + self.stats_before["median"] + 3 * self.stats_before["nmad"], + self.stats_after["median"] + 3 * self.stats_after["nmad"], + ) + self.generate_plot( - diff, - title=f"Difference\n{label} coregistration", - filename=f"diff{suffix}", + dem=self.diff_before, + title="Elevation difference before coregistration", + filename="diff_elev_diff_coreg_map", + dem_right=self.diff_after, + title_dem_right="Elevation difference after coregistration", + vmin=vmin_diff, + vmax=vmax_diff, + cmap="RdBu", + cbar_title=f"Elevation differences ({self.diff_before.crs.linear_units})", + ) + + else: + self.diff = self.to_be_aligned_elev - ref_elev + self.stats = self.diff.get_stats(stats_keys) + vmin, vmax = -(self.stats["median"] + 3 * self.stats["nmad"]), self.stats["median"] + 3 * self.stats["nmad"] + self.generate_plot( + self.diff, + title="Elevation difference without coregistration", + filename="diff_elev_without_coreg_map", vmin=vmin, vmax=vmax, cmap="RdBu", - cbar_title="Elevation differences (m)", + cbar_title=f"Elevation differences ({self.diff.crs.linear_units})", ) - if self.compute_coreg: stat_items = [ (self.reference_elev, "reference_elev", "Reference elevation", 2), @@ -375,7 +388,7 @@ def run(self) -> None: stat_items = [ (self.reference_elev, "reference_elev", "Reference elevation", 2), (self.to_be_aligned_elev, "to_be_aligned_elev", "To-be-aligned elevation", 2), - (self.diff, "diff_elev", "Elevation difference", 2), + (self.diff, "diff_elev_without_coreg", "Elevation difference without coregistration", 2), ] list_df_var = [] @@ -391,6 +404,7 @@ def run(self) -> None: if len(list_df_var) > 0: df_stats = pd.concat(list_df_var) + df_stats.set_index("Data", inplace=True) else: df_stats = None self.df_stats = df_stats @@ -402,7 +416,7 @@ def run(self) -> None: self.diff_after.to_file(self.outputs_folder / "rasters" / "diff_elev_after_coreg_map.tif") else: if self.level > 1: - self.diff.to_file(self.outputs_folder / "rasters" / "diff_elev.tif") + self.diff.to_file(self.outputs_folder / "rasters" / "diff_elev_without_coreg_map.tif") t1 = time.time() self.elapsed = t1 - t0 @@ -435,17 +449,15 @@ def create_html(self, list_dict: list[tuple[str, dict[str, Any]]]) -> None: html += f"

Computing time: {self.elapsed:.2f} seconds

" # Plot input elevation data - html += "

Elevation datasets

\n" - html += "
\n" - html += ( - " Image PNG\n" - ) - html += ( - " Image PNG\n" - ) - html += "
\n" + html += "

Elevation inputs

\n" + html += "Image PNG\n" + + if ( + "path_to_mask" in self.config["inputs"]["reference_elev"] + or "path_to_mask" in self.config["inputs"]["to_be_aligned_elev"] + ): + html += "

Masked elevation data

\n" + html += "Image PNG\n" def format_values(val: Any) -> Any: """Format values for the dictionary.""" @@ -456,52 +468,63 @@ def format_values(val: Any) -> Any: else: return str(val) - # Metadata: Inputs, coregistration - for title, dictionary in list_dict: # type: ignore - html += "
\n" - html += f"

{title}

\n" - html += "\n" - html += "\n" + def print_dict(title: str, dictionary: dict[str, Any]) -> str: + div_html = "
\n" + div_html += f"

{title}

\n" + div_html += "
InformationValue
\n" + div_html += "\n" for key, value in dictionary.items(): if isinstance(value, dict): value = {k: format_values(v) for k, v in value.items()} - html += f"\n" - html += "
InformationValue
{key}{value}
\n" - html += "
\n" + div_html += f"{key}{value}\n" + div_html += "\n" + div_html += "\n" + return div_html + + # Metadata: Inputs + inputs_information = list_dict[0] + html += print_dict(inputs_information[0], inputs_information[1]) + + # Plot preprocessed data if did + if "sampling_grid" in self.config["inputs"] and self.config["inputs"]["sampling_grid"] is not None: + if self.config["inputs"]["sampling_grid"] == "reference_elev": + preprocessed_data = "plots/preprocessed_to_be_aligned_elev_map.png" + else: + preprocessed_data = "plots/preprocessed_reference_elev_map.png" + + html += "

Preprocessed elevation data

\n" + html += "Image PNG\n" + + # Metadata: Inputs + for title, dictionary in list_dict[1:]: # type: ignore + html += print_dict(title, dictionary) # Statistics table: if self.df_stats is not None: html += "

Statistics

\n" - html += self.df_stats.to_html(index=False) + html += "\n" + # Plot one stat by row + df_cols = "".join([f'' for col in self.df_stats.T.columns]) + html += f'{df_cols}\n' + for key, value in self.df_stats.T.iterrows(): + df_values = "".join([f"" for val in value.values]) + html += f"{df_values}\n" + html += "
{col}
Data
{self.format_values_stats(key, val)}
{key}
\n" # Coregistration: Add elevation difference plot and histograms before/after if self.compute_coreg: html += "

Elevation differences

\n" - html += "
\n" - html += ( - " Image PNG\n" - ) - html += ( - " Image PNG\n" - ) - html += "
\n" + html += "Image PNG\n" html += "

Differences histogram

\n" - html += "Image PNG\n" + html += "Image PNG\n" else: html += "

Elevation differences

\n" - html += "
\n" html += ( - " Image PNG\n" + "Image PNG\n" ) - html += "
\n" - html += """ - """ diff --git a/xdem/workflows/schemas.py b/xdem/workflows/schemas.py index 06a706784..8b77430a9 100644 --- a/xdem/workflows/schemas.py +++ b/xdem/workflows/schemas.py @@ -92,6 +92,19 @@ def _validate_crs(self, crs: bool, field: str, value: str | int) -> bool: COREG_METHODS = ["NuthKaab", "DhMinimize", "VerticalShift", "DirectionalBias", "TerrainBias", "LZD", None] + +MIN_STATS = [ + "min", + "max", + "mean", + "median", + "standarddeviation", + "nmad", + "validcount", + "totalcount", + "percentagevalidpoints", +] + STATS_METHODS = [ "mean", "median", @@ -168,7 +181,7 @@ def validate_configuration(user_config: dict[str, Any], schema: dict[str, Any]) raise ValueError(f"User configuration invalid for '{field}': {errors}") if "statistics" not in validator.document: - validator.document["statistics"] = STATS_METHODS + validator.document["statistics"] = MIN_STATS if "terrain_attributes" not in validator.document and "coregistration" not in validator.document: validator.document["terrain_attributes"] = TERRAIN_ATTRIBUTES_DEFAULT @@ -210,9 +223,9 @@ def validate_configuration(user_config: dict[str, Any], schema: dict[str, Any]) "coregistration": { "type": "dict", "required": False, - "default": {"step_one": {"method": "NuthKaab"}}, + "default": {"step_one": {"method": "LZD", "extra_information": {"subsample": 10000}}}, "schema": { - "step_one": make_coreg_step(default_method="NuthKaab"), + "step_one": make_coreg_step(default_method="LZD"), "step_two": make_coreg_step(required=False), "step_three": make_coreg_step(required=False), "process": {"type": "boolean", "default": True, "required": False}, @@ -290,8 +303,8 @@ def validate_configuration(user_config: dict[str, Any], schema: dict[str, Any]) }, "coregistration": { "step_one": { - "method": "NuthKaab", - "extra_information": None, + "method": "LZD", + "extra_information": {"subsample": 10000}, }, "step_two": { "method": None, @@ -303,23 +316,7 @@ def validate_configuration(user_config: dict[str, Any], schema: dict[str, Any]) }, "process": True, }, - "statistics": [ - "mean", - "median", - "max", - "min", - "sum", - "sumofsquares", - "90thpercentile", - "le90", - "nmad", - "rmse", - "std", - "standarddeviation", - "validcount", - "totalcount", - "percentagevalidpoints", - ], + "statistics": MIN_STATS, } COMPLETE_CONFIG_TOPO = { @@ -334,22 +331,6 @@ def validate_configuration(user_config: dict[str, Any], schema: dict[str, Any]) }, }, "outputs": {"level": 1, "path": "outputs"}, - "statistics": [ - "mean", - "median", - "max", - "min", - "sum", - "sumofsquares", - "90thpercentile", - "le90", - "nmad", - "rmse", - "std", - "standarddeviation", - "validcount", - "totalcount", - "percentagevalidpoints", - ], + "statistics": MIN_STATS, "terrain_attributes": ["slope", "aspect", "max_curvature"], } diff --git a/xdem/workflows/topo.py b/xdem/workflows/topo.py index 2722641d8..eddad5f99 100644 --- a/xdem/workflows/topo.py +++ b/xdem/workflows/topo.py @@ -30,7 +30,7 @@ import xdem from xdem._misc import import_optional from xdem.workflows.schemas import TOPO_SCHEMA -from xdem.workflows.workflows import Workflows +from xdem.workflows.workflows import _ALIAS, Workflows class Topo(Workflows): @@ -68,16 +68,21 @@ def _load_data(self) -> None: """ self.dem, self.inlier_mask, path_to_mask = self.load_dem(self.config["inputs"]["reference_elev"]) - self.generate_plot(self.dem, filename="elev_map", title="Elevation", cmap="terrain", cbar_title="Elevation (m)") + self.generate_plot( + self.dem, + filename="elev_map", + title="Elevation", + cbar_title=f"Elevation ({self.dem.crs.linear_units})", + ) if self.inlier_mask is not None: + inlier_mask_crop = self.inlier_mask.reproject(self.dem).crop(self.dem) + self.dem.set_mask(~inlier_mask_crop) self.generate_plot( self.dem, title="Masked elevation", filename="masked_elev_map", - mask_path=path_to_mask, - cmap="terrain", - cbar_title="Elevation (m)", + cbar_title=f"Elevation ({self.dem.crs.linear_units})", ) def generate_terrain_attributes_tiff(self) -> None: @@ -128,26 +133,26 @@ def generate_terrain_attributes_png(self) -> None: ncols = 2 nrows = math.ceil(n / ncols) - + unit = self.dem.crs.linear_units attribute_params: dict[str, dict[str, Any]] = { "hillshade": {"label": "Hillshade", "cmap": "Greys_r", "vlim": (0, 255)}, "texture_shading": {"label": "Texture shading", "cmap": "Greys_r", "vlim": (-20, 20)}, "slope": {"label": "Slope (°)", "cmap": "Reds", "vlim": (0, 90)}, "aspect": {"label": "Aspect (°)", "cmap": "twilight", "vlim": (0, 360)}, - "profile_curvature": {"label": "Profile curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, - "tangential_curvature": {"label": "Tangential curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, - "planform_curvature": {"label": "Planform curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, - "flowline_curvature": {"label": "Flowline curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, - "max_curvature": {"label": "Max. curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, - "min_curvature": {"label": "Min. curvature (100 / m)", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "profile_curvature": {"label": f"Profile curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "tangential_curvature": {"label": f"Tangential curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "planform_curvature": {"label": f"Planform curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "flowline_curvature": {"label": f"Flowline curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "max_curvature": {"label": f"Max. curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, + "min_curvature": {"label": f"Min. curvature (100/{unit})", "cmap": "RdGy_r", "vlim": (-2, 2)}, "terrain_ruggedness_index": {"label": "Terrain Ruggedness Index", "cmap": "Purples", "vlim": (None, None)}, "rugosity": {"label": "Rugosity", "cmap": "YlOrRd", "vlim": (None, None)}, "topographic_position_index": { - "label": "Topographic position index (m)", + "label": f"Topographic position index ({unit})", "cmap": "Spectral", "vlim": (None, None), }, - "roughness": {"label": "Roughness (m)", "cmap": "Oranges", "vlim": (None, None)}, + "roughness": {"label": f"Roughness ({self.dem.crs.linear_units})", "cmap": "Oranges", "vlim": (None, None)}, "fractal_dimension": {"label": "Fractal roughness (dimensions)", "cmap": "Reds", "vlim": (None, None)}, } @@ -155,6 +160,14 @@ def generate_terrain_attributes_png(self) -> None: import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows, ncols) + size_font = 6 + plt.rc("font", size=size_font) + plt.rc("axes", titlesize=size_font) + plt.rc("axes", labelsize=size_font) + plt.rc("xtick", labelsize=size_font) + plt.rc("ytick", labelsize=size_font) + plt.rc("legend", fontsize=size_font) + plt.rc("figure", titlesize=size_font) axes = axes.flatten() for i, attr in enumerate(self.list_attributes): @@ -168,6 +181,7 @@ def generate_terrain_attributes_png(self) -> None: ax.set_xticks([]) ax.set_yticks([]) + [fig.delaxes(ax) for ax in axes.flatten() if not ax.has_data()] plt.tight_layout() plt.savefig(self.outputs_folder / "plots" / "terrain_attributes_map.png", dpi=300) plt.close() @@ -203,12 +217,9 @@ def run(self) -> None: list_metrics = self.config["statistics"] if list_metrics is not None: stats_dem = self.dem.get_stats(list_metrics) + stats_dem = {_ALIAS.get(k, k): v for k, v in stats_dem.items()} self.save_stat_as_csv(stats_dem, "stats_elev") - self.dico_to_show.append(("Global statistics", self.floats_process(stats_dem))) - stats_dem_mask = self.dem.get_stats(list_metrics, inlier_mask=self.inlier_mask) - if self.inlier_mask is not None: - self.save_stat_as_csv(stats_dem_mask, "stats_elev_mask") - self.dico_to_show.append(("Mask statistics", self.floats_process(stats_dem_mask))) + self.dico_to_show.append(("Statistics", self.floats_process(stats_dem))) logging.info(f"Computing metrics on reference elevation: {list_metrics}") # Terrain attributes @@ -248,25 +259,28 @@ def create_html(self, list_dict: list[tuple[str, dict[str, Any]]]) -> None: html += f"

Date: {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}

" html += f"

Computing time: {self.elapsed:.2f} seconds

" - html += "

Elevation data

\n" - html += "Image PNG\n" + html += "

Elevation input

\n" + html += "Image PNG\n" if self.inlier_mask is not None: html += "

Masked elevation data

\n" - html += "Image PNG\n" + html += "Image PNG\n" for title, dictionary in list_dict: html += "
\n" # type: ignore html += f"

{title}

\n" html += "\n" html += "\n" - for key, value in dictionary.items(): - html += f"\n" + for key, val in dictionary.items(): + if "statistics" in title.lower(): + html += f"\n" + else: + html += f"\n" html += "
InformationValue
{key}{value}
{key}{self.format_values_stats(key, val)}
{key}{val}
\n" html += "
\n" html += "

Terrain attributes

\n" - html += "Image PNG\n" + html += "Image PNG\n" html += "\n" diff --git a/xdem/workflows/workflows.py b/xdem/workflows/workflows.py index 73c457194..21eea9a8e 100644 --- a/xdem/workflows/workflows.py +++ b/xdem/workflows/workflows.py @@ -47,6 +47,24 @@ SafeDumper = object _HAS_YAML = False +_ALIAS = { + "mean": "Mean", + "median": "Median", + "max": "Maximum", + "min": "Minimum", + "sum": "Sum", + "sumofsquares": "Sum of squares", + "90thpercentile": "90th percentile", + "le90": "LE90", + "nmad": "NMAD", + "rmse": "RMSE", + "std": "Standard deviation", + "standarddeviation": "Standard deviation", + "validcount": "Valid count", + "totalcount": "Total count", + "percentagevalidpoints": "Percentage valid points", +} + class Workflows(ABC): """ @@ -142,34 +160,63 @@ def replace_none_str_with_none_type(some_dict: Dict[str, Any]) -> Dict[str, Any] return replace_none_str_with_none_type(yaml.safe_load(f)) - def generate_plot(self, dem: RasterType, title: str, filename: str, mask_path: str = None, **kwargs: Any) -> None: + def generate_plot( + self, + dem: RasterType, + title: str, + filename: str, + dem_right: str = None, + title_dem_right: str = None, + **kwargs: Any, + ) -> None: """ Generate plot from a DEM. - :param dem: Input digital elevation model. - :param title: Title of figure. + :param dem: Input digital elevation model (left) + :param title: Title of dem plot (left) :param filename: Filename of figure. + :param dem_right: Input digital elevation model (right) + :param title_dem_right: Title of dem_right plot (right) :param mask_path: Path to mask file. - :return: None """ - import_optional("matplotlib") import matplotlib.pyplot as plt - if mask_path is None: - dem.plot(**kwargs) - plt.title(title) - plt.savefig(self.outputs_folder / "plots" / f"{filename}.png", dpi=300) - plt.close() + size_font = 6 + plt.rc("font", size=size_font) + plt.rc("axes", titlesize=size_font) + plt.rc("axes", labelsize=size_font) + plt.rc("xtick", labelsize=size_font) + plt.rc("ytick", labelsize=size_font) + plt.rc("legend", fontsize=size_font) + plt.rc("figure", titlesize=size_font) + + # Apply default cmap if not given in inputs + if "cmap" in kwargs: + print(kwargs["cmap"]) + cmap = plt.get_cmap(name=kwargs["cmap"]) + else: + cmap = plt.get_cmap(name="terrain") + cmap.set_bad(color="k", alpha=None) + kwargs["cmap"] = cmap + + # Force figsize with the good ratio to prevent larger right axe if not filled + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=[6.4, 2.4]) + + # Add the first image to the figure (left position) + dem.plot(ax=ax1, **kwargs) + plt.title(title) + + # If exists, add the second image to the figure + if dem_right is not None: + dem_right.plot(ax=ax2, **kwargs) + plt.title(title_dem_right) else: - mask = gu.Vector(mask_path) - mask = mask.crop(dem) - dem.plot(**kwargs) - mask.plot(dem, ec="k", fc="none") - plt.title(title) - plt.savefig(self.outputs_folder / "plots" / f"{filename}.png", dpi=300) - plt.close() + ax2.set_axis_off() + + plt.savefig(self.outputs_folder / "plots" / f"{filename}.png", dpi=300, bbox_inches="tight") + plt.close() def floats_process( self, dict_with_floats: Dict[str, Any] | InputCoregDict | OutputCoregDict | Any @@ -291,3 +338,16 @@ def save_stat_as_csv(self, data: dict[str, float], file_name: str) -> None: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() writer.writerow(cleaned_data) + + def format_values_stats(self, key: str, val: Union[float, int]) -> str: + """Format values for the statistics.""" + if "count" in key.lower(): + return str(int(val)) + if "percentage" in key.lower(): + return f"{val:.2f}" + "%" + elif abs(val) > 10e4: + return np.format_float_scientific(val, precision=3) + elif abs(val) < 10e-4: + return np.format_float_scientific(val, precision=3) + else: + return f"{val:.3f}"