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
303 changes: 157 additions & 146 deletions src/geogenalg/application/generalize_fences.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,160 +6,171 @@
# LICENSE file in the root directory of this source tree.

from dataclasses import dataclass
from pathlib import Path
from typing import override

import geopandas as gpd
import pandas as pd
from cartagen.utils.partitioning.network import network_faces
from geopandas import GeoDataFrame
from pandas import concat

from geogenalg import continuity, merge, selection
from geogenalg.application import BaseAlgorithm
from geogenalg.core.exceptions import GeometryTypeError
from geogenalg.utility.validation import check_gdf_geometry_type


@dataclass
class AlgorithmOptions:
"""Options for generalize fences algorithm.
@dataclass(frozen=True)
class GeneralizeFences(BaseAlgorithm):
"""Generalize lines representing fences.

Attributes:
closing_fence_area_threshold: Minimum area for a fence-enclosed region
closing_fence_area_with_mast_threshold: Minimum area for a fence-enclosed
region containing a mast
fence_length_threshold: Minimum length for a fence line
fence_length_threshold_in_closed_area: Minimum length for a fence line within a
closed area
simplification_tolerance: Tolerance used for geometry simplification
gap_threshold: Maximum gap between two fence lines to be connected with
a helper line
attribute_for_line_merge: Name of the attribute to determine which line
features can be merged
Reference data should contain a Point GeoDataFrame with the key
"masts".

"""

closing_fence_area_threshold: float
closing_fence_area_with_mast_threshold: float
fence_length_threshold: float
fence_length_threshold_in_closed_area: float
simplification_tolerance: float
gap_threshold: float
attribute_for_line_merge: str


def create_generalized_fences(
input_path: Path | str,
fences_layer_name: str,
masts_layer_name: str,
options: AlgorithmOptions,
output_path: str,
) -> None:
"""Create GeoDataFrames and pass them to the generalization function.

Args:
----
input_path: Path to the input GeoPackage
fences_layer_name: Name of the layer for fences
masts_layer_name: Name of the layer for masts
options: Algorithm parameters for generalize fences
output_path: Path to save the output GeoPackage

Raises:
------
FileNotFoundError: If the input_path does not exist
Output contains the generalized line fences.

The algorithm does the following steps:
- Merges line segments
- Adds helper lines to close small gaps between lines
- Removes short lines within large enough enclosed areas
- Removes surrounding lines of small enough enclosed areas
- Removes close enough lines surrounding a mast
- Removes all short lines
- Simplifies all lines
"""
if not Path(input_path).resolve().exists():
raise FileNotFoundError

fences_gdf = gpd.read_file(input_path, layer=fences_layer_name)

masts_gdf = gpd.read_file(input_path, layer=masts_layer_name)

result = generalize_fences(
fences_gdf,
masts_gdf,
options,
)

result.to_file(output_path, driver="GPKG")


def generalize_fences(
fences_gdf: gpd.GeoDataFrame, masts_gdf: gpd.GeoDataFrame, options: AlgorithmOptions
) -> gpd.GeoDataFrame:
"""Generalize the LineString layer representing fences.

Args:
----
fences_gdf: A GeoDataFrame containing the fence lines to be generalized
masts_gdf: A GeoDataFrame containing masts
options: Algorithm parameters for generalize fences

Returns:
-------
Generalized fence lines

"""
result_gdf = fences_gdf.copy()

# Merge connecting lines with the same attribute value
result_gdf = merge.merge_connecting_lines_by_attribute(
result_gdf, options.attribute_for_line_merge
)

# Generate helper lines to close small gaps
helper_lines_gdf = continuity.connect_nearby_endpoints(
result_gdf, options.gap_threshold
)

# Combine original fence lines with helper lines
combined_gdf: gpd.GeoDataFrame
combined_gdf = pd.concat([result_gdf, helper_lines_gdf], ignore_index=True)

# Calculate the CartaGen network faces to fill closing line geometries
faces = network_faces(list(combined_gdf.geometry), convex_hull=False)
faces_gdf = gpd.GeoDataFrame(geometry=list(faces))

# Dissolve adjacent polygons into larger contiguous areas
faces_gdf = faces_gdf.dissolve(as_index=False)
faces_gdf = faces_gdf.union_all()

# Select fence lines shorter than the threshold for enclosed areas
short_lines = result_gdf[
result_gdf.geometry.length < options.fence_length_threshold_in_closed_area
]

# Remove short fence lines that are within closed polygonal areas
to_remove_idx = short_lines[short_lines.geometry.apply(faces_gdf.contains)].index
result_gdf = result_gdf.drop(index=to_remove_idx)

# Convert MultiPolygon result back into a proper GeoDataFrame
polygons = list(faces_gdf.geoms)
faces_gdf = gpd.GeoDataFrame(geometry=polygons, crs=fences_gdf.crs)

# Remove polygons whose area exceeds the closing_fence_area_with_mast_threshold and
# the closing_fence_area_threshold
polygon_gdf_with_point, polygon_gdf_without_point = (
selection.split_polygons_by_point_intersection(faces_gdf, masts_gdf)
)
polygon_gdf_with_point = selection.remove_large_polygons(
polygon_gdf_with_point, options.closing_fence_area_with_mast_threshold
)
polygon_gdf_without_point = selection.remove_large_polygons(
polygon_gdf_without_point, options.closing_fence_area_threshold
)

# Combine filtered polygons
faces_gdf = pd.concat(
[polygon_gdf_with_point, polygon_gdf_without_point], ignore_index=True
)

# Remove the surrounding fence lines of small closed areas with masts considered
result_gdf = selection.remove_parts_of_lines_on_polygon_edges(result_gdf, faces_gdf)

# Remove short fence lines
result_gdf = selection.remove_disconnected_short_lines(
result_gdf, options.fence_length_threshold
)

# Return simplified fence lines
result_gdf.geometry = result_gdf.geometry.simplify(options.simplification_tolerance)

return result_gdf
closing_fence_area_threshold: float = 2000.0
"""Minimum area for a fence-enclosed region."""
closing_fence_area_with_mast_threshold: float = 8000.0
"""Minimum area for a fence-enclosed region containing a mast."""
fence_length_threshold: float = 80.0
"""Minimum length for a fence line."""
fence_length_threshold_in_closed_area: float = 300.0
"""Minimum length for a fence line within a closed area."""
simplification_tolerance: float = 4.0
"""Tolerance used for geometry simplification."""
gap_threshold: float = 25.0
"""Maximum gap between two fence lines to be connected with a helper line."""
attribute_for_line_merge: str = "kohdeluokka"
"""Name of the attribute to determine which line features can be merged."""

@override
def _execute(
self,
data: GeoDataFrame,
reference_data: dict[str, GeoDataFrame],
) -> GeoDataFrame:
"""Execute algorithm.

Args:
----
data: A GeoDataFrame containing the fence lines to be generalized.
reference_data: Should contain a Point GeoDataFrame with the key
"masts".

Returns:
-------
GeoDataFrame containing the generalized fence lines.

Raises:
------
GeometryTypeError: If `data` contains non-line geometries or the
GeoDataFrame with key "masts" in `reference_data` contains
non-point geometries.
KeyError: If `reference_data` does not contain data with key
"masts" or input data does not have specified
`attribute_for_line_merge`.

"""
if not check_gdf_geometry_type(data, ["LineString"]):
msg = "GeneralizeFences works only with LineString geometries."
raise GeometryTypeError(msg)
if "masts" not in reference_data:
msg = (
"GeneralizeFences requires mast Point GeoDataFrame"
+ " in reference_data with key 'masts'."
)
raise KeyError(msg)
if not check_gdf_geometry_type(reference_data["masts"], ["Point"]):
msg = "Masts data should be a Point GeoDataFrame."
raise GeometryTypeError(msg)
if self.attribute_for_line_merge not in data.columns:
msg = (
"Specified `attribute_for_line_merge` "
+ f"({self.attribute_for_line_merge}) not found in input GeoDataFrame."
)
raise KeyError(msg)

result_gdf = data.copy()

# Merge connecting lines with the same attribute value
result_gdf = merge.merge_connecting_lines_by_attribute(
result_gdf, self.attribute_for_line_merge
)

# Generate helper lines to close small gaps
helper_lines_gdf = continuity.connect_nearby_endpoints(
result_gdf, self.gap_threshold
)

# Combine original fence lines with helper lines
combined_gdf: GeoDataFrame = concat(
[result_gdf, helper_lines_gdf], ignore_index=True
)

# Calculate the CartaGen network faces to fill closing line geometries
faces = network_faces(list(combined_gdf.geometry), convex_hull=False)
faces_gdf = GeoDataFrame(geometry=list(faces))

# Dissolve adjacent polygons into larger contiguous areas
faces_gdf = faces_gdf.dissolve(as_index=False)
faces_gdf = faces_gdf.union_all()

# Select fence lines shorter than the threshold for enclosed areas
short_lines = result_gdf[
result_gdf.geometry.length < self.fence_length_threshold_in_closed_area
]

# Remove short fence lines that are within closed polygonal areas
to_remove_idx = short_lines[
short_lines.geometry.apply(faces_gdf.contains)
].index
result_gdf = result_gdf.drop(index=to_remove_idx)

# Convert MultiPolygon result back into a proper GeoDataFrame
polygons = list(faces_gdf.geoms)
faces_gdf = GeoDataFrame(geometry=polygons, crs=data.crs)

# Remove polygons whose area exceeds the closing_fence_area_with_mast_threshold
# and the closing_fence_area_threshold
polygon_gdf_with_point, polygon_gdf_without_point = (
selection.split_polygons_by_point_intersection(
faces_gdf, reference_data["masts"]
)
)
polygon_gdf_with_point = selection.remove_large_polygons(
polygon_gdf_with_point, self.closing_fence_area_with_mast_threshold
)
polygon_gdf_without_point = selection.remove_large_polygons(
polygon_gdf_without_point, self.closing_fence_area_threshold
)

# Combine filtered polygons
faces_gdf = concat(
[polygon_gdf_with_point, polygon_gdf_without_point], ignore_index=True
)

# Remove the surrounding fence lines of small closed areas with masts considered
result_gdf = selection.remove_parts_of_lines_on_polygon_edges(
result_gdf, faces_gdf
)

# Remove short fence lines
result_gdf = selection.remove_disconnected_short_lines(
result_gdf, self.fence_length_threshold
)

# Return simplified fence lines
result_gdf.geometry = result_gdf.geometry.simplify(
self.simplification_tolerance
)

return result_gdf
2 changes: 2 additions & 0 deletions src/geogenalg/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from geogenalg.application.generalize_clusters_to_centroids import (
GeneralizePointClustersAndPolygonsToCentroids,
)
from geogenalg.application.generalize_fences import GeneralizeFences
from geogenalg.utility.dataframe_processing import read_gdf_from_file_and_set_index

GEOPACKAGE_URI_HELP = (
Expand Down Expand Up @@ -270,6 +271,7 @@ def build_app() -> None:
"""
commands_and_algs = {
"clusters_to_centroids": GeneralizePointClustersAndPolygonsToCentroids,
"fences": GeneralizeFences,
}

for cli_command_name, alg in commands_and_algs.items():
Expand Down
Loading