Skip to content

Commit

Permalink
Implement Building Completion
Browse files Browse the repository at this point in the history
  • Loading branch information
CharlesGaydon committed Mar 21, 2022
1 parent ad12986 commit b816349
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 124 deletions.
2 changes: 1 addition & 1 deletion CI/run_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ HYDRA_FULL_ERROR=1
python -m lidar_prod.run print_config=true \
paths.src_las=/var/data/cicd/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/subsets/871000_6617000_subset_with_probas.las \
paths.output_dir=/var/data/cicd/CICD_outputs/app/ \
data_format.codes.candidates.building='[19, 20, 110, 112, 114, 115]' \
data_format.codes.building.candidates='[19, 20, 110, 112, 114, 115]' \
building_validation.application.building_validation_thresholds_pickle=/var/data/cicd/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/M8.0B2V0.0_buildingvalidation_thresholds.pickle
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ pip install --upgrade https://github.com/IGNF/lidar-prod-quality-control/tarball
pip install -e . # from local sources
```

To run the module as a package, you will need a source cloud point in LAS format with an additional channel containing predicted building probabilities. The name of this channel is specified by `config.data_format.las_channel_names.ai_building_proba`.
To run the module as a package, you will need a source cloud point in LAS format with an additional channel containing predicted building probabilities. The name of this channel is specified by `config.data_format.las_dimensions.ai_building_proba`.

To run using default configurations of the installed package, use
```bash
Expand Down
15 changes: 15 additions & 0 deletions configs/building_completion/default.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
_target_: lidar_prod.tasks.building_completion.BuildingCompletor

data_format: ${data_format}

# TODO: uncomment when min_building_proba_relaxation_if_bd_uni_overlay is specified <1 to be consistent everywhere.
# min_building_proba: ${building_validation.application.rules.min_confidence_confirmation}

min_building_proba: 0.5
min_building_proba_relaxation_if_bd_uni_overlay: 1.0 # No effect if = 1.0

cluster:
min_points: 10 # including isolated points (in BuildingValidator) and confirmed candidates points.
tolerance: 1 # meters
is3d: false # group in 2d for better detection

6 changes: 2 additions & 4 deletions configs/building_identification/default.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
_target_: lidar_prod.tasks.building_identification.BuildingIdentifier

candidate_buildings_codes: ${data_format.codes.candidates.building}

data_format: ${data_format}

min_building_proba: ${building_validation.application.rules.min_confidence_confirmation}
min_building_proba_multiplier_if_bd_uni_overlay: 1.0
min_building_proba_relaxation_if_bd_uni_overlay: 1.0

cluster:
min_points: 200
min_points: 200 # Large so that small artefact are ignored
tolerance: 1 # meters
is3d: false # group in 2d for better detection

16 changes: 0 additions & 16 deletions configs/building_validation/application/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ _target_: lidar_prod.tasks.building_validation.BuildingValidator
building_validation_thresholds_pickle: /path/to/best_trial.pkl

data_format: ${data_format}
candidate_buildings_codes: ${data_format.codes.candidates.building}
use_final_classification_codes: true


Expand All @@ -27,18 +26,3 @@ rules:
min_uni_db_overlay_frac: 0.508
min_confidence_refutation: 0.872
min_frac_refutation: 0.964

codes:
detailed:
unclustered: 202 # refuted
ia_refuted: 110 # refuted
ia_refuted_and_db_overlayed: 111 # unsure
both_unsure: 112 # unsure
ia_confirmed_only: 113 # confirmed
db_overlayed_only: 114 # confirmed
both_confirmed: 115 # confirmed
final:
unsure: 214 # unsure
not_building: 208 # refuted
building: 6 # confirmed

1 change: 1 addition & 0 deletions configs/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ defaults:
- data_format: default.yaml
- building_validation: default.yaml
- building_identification: default.yaml
- building_completion: default.yaml
- _self_ # needed by pdal for legacy reasons
4 changes: 2 additions & 2 deletions configs/data_format/cleaning/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ _target_: lidar_prod.tasks.cleaning.Cleaner
# Extra dims that are kept when cleaning dimensions
# You can override with "all" to keep all extra dimensions at development time.
keep_extra_dims:
- "${data_format.las_channel_names.ai_building_identified}=uint"
- "${data_format.las_channel_names.ai_building_proba}=float"
- "${data_format.las_dimensions.ai_building_identified}=uint"
- "${data_format.las_dimensions.ai_building_proba}=float"
36 changes: 26 additions & 10 deletions configs/data_format/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,38 @@ tile_size_meters: 1000
crs_prefix: "EPSG:"
crs: 2154

# To connect logic between application tasks
las_channel_names:
# Those names connect the logics between successive tasks
las_dimensions:
# input
classification: classification #las format
cluster_id: ClusterID #pdal-defined
uni_db_overlay: BDTopoOverlay # user-defined
ai_building_proba: building # user-defined
ai_building_proba: building # user-defined - output by deep learning model

# custom channels
candidate_buildings_flag: CandidateBuildingsFlag
macro_candidate_building_groups: CandidateBuildingGroups
# intermediary channels
cluster_id: ClusterID # pdal-defined -> created by clustering operations
uni_db_overlay: BDTopoOverlay # user-defined -> a 0/1 flag for presence of a BDUni vector
candidate_buildings_flag: F_CandidateB # -> a 0/1 flag identifying candidate buildings found by rules-based classification
ClusterID_candidate_building: CID_CandidateB # -> Cluster index from BuildingValidator, 0 if no cluster, 1-n elsewise
ClusterID_isolated_plus_confirmed: CID_IsolatedOrConfirmed # -> Cluster index from BuildingCompletor, 0 if no cluster, 1-n elsewise


# additionnal output channel
ai_building_identified: Group

codes:
candidates:
building: [202]
building:
candidates: [202] # found by rules-based classification (TerraScan)
detailed: # used for detailed output when doing threshold optimization
unclustered: 202 # refuted
ia_refuted: 110 # refuted
ia_refuted_and_db_overlayed: 111 # unsure
both_unsure: 112 # unsure
ia_confirmed_only: 113 # confirmed
db_overlayed_only: 114 # confirmed
both_confirmed: 115 # confirmed
final: # used at the end of the building process
unsure: 214 # unsure
not_building: 208 # refuted
building: 6 # confirmed

defaults:
- cleaning: default.yaml
30 changes: 15 additions & 15 deletions dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# all the apt-get installs
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
software-properties-common \
wget \
git \
postgis \
pdal \
libgl1-mesa-glx libegl1-mesa libxrandr2 libxrandr2 libxss1 libxcursor1 libxcomposite1 libasound2 libxi6 libxtst6 # package needed for anaconda
software-properties-common \
wget \
git \
postgis \
pdal \
libgl1-mesa-glx libegl1-mesa libxrandr2 libxrandr2 libxss1 libxcursor1 libxcomposite1 libasound2 libxi6 libxtst6 # package needed for anaconda

# install anaconda
RUN wget --quiet https://repo.anaconda.com/archive/Anaconda3-2021.11-Linux-x86_64.sh -O ~/anaconda.sh
Expand All @@ -40,15 +40,15 @@ RUN python -c "import pdal"

# the entrypoint garanty that all command will be runned in the conda environment
ENTRYPOINT ["conda", \
"run", \
"-n", \
"lidar_prod"]
"run", \
"-n", \
"lidar_prod"]

# cmd for a normal run (non evaluate)
CMD ["python", \
"lidar_prod/run.py", \
"print_config=true", \
"paths.src_las=/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/subsets/871000_6617000_subset_with_probas.las", \
"paths.output_dir=/CICD_github_assets/app/", \
"data_format.codes.candidates.building=[19, 20, 110, 112, 114, 115]", \
"building_validation.application.building_validation_thresholds_pickle=/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/M8.0B2V0.0_buildingvalidation_thresholds.pickle"]
"lidar_prod/run.py", \
"print_config=true", \
"paths.src_las=/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/subsets/871000_6617000_subset_with_probas.las", \
"paths.output_dir=/CICD_github_assets/app/", \
"data_format.codes.building.candidates=[19, 20, 110, 112, 114, 115]", \
"building_validation.application.building_validation_thresholds_pickle=/CICD_github_assets/M8.0/20220204_building_val_V0.0_model/M8.0B2V0.0_buildingvalidation_thresholds.pickle"]
25 changes: 17 additions & 8 deletions lidar_prod/application.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging
import os
import os.path as osp
from tempfile import TemporaryDirectory
import hydra
from omegaconf import DictConfig
from typing import Optional
from lidar_prod.tasks.building_completion import BuildingCompletor
from lidar_prod.tasks.cleaning import Cleaner

from lidar_prod.utils import utils
Expand All @@ -30,13 +32,20 @@ def apply(config: DictConfig):
in_f = config.paths.src_las
out_f = osp.join(config.paths.output_dir, osp.basename(in_f))

bv: BuildingValidator = hydra.utils.instantiate(
config.building_validation.application
)
bv.run(in_f, out_f)
with TemporaryDirectory() as td:
# Temporary LAS file for intermediary results.
temp_f = osp.join(td, osp.basename(in_f))

bi: BuildingIdentifier = hydra.utils.instantiate(config.building_identification)
bi.run(out_f, out_f)
bv: BuildingValidator = hydra.utils.instantiate(
config.building_validation.application
)
bv.run(in_f, temp_f)

cl: Cleaner = hydra.utils.instantiate(config.data_format.cleaning)
cl.run(out_f, out_f)
bc: BuildingCompletor = hydra.utils.instantiate(config.building_completion)
bc.run(temp_f, temp_f)

bi: BuildingIdentifier = hydra.utils.instantiate(config.building_identification)
bi.run(temp_f, temp_f)

cl: Cleaner = hydra.utils.instantiate(config.data_format.cleaning)
cl.run(temp_f, out_f)
148 changes: 148 additions & 0 deletions lidar_prod/tasks/building_completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import logging
import os
import os.path as osp
from tempfile import TemporaryDirectory
import pdal
import laspy
from tqdm import tqdm

from lidar_prod.tasks.utils import split_idx_by_dim

log = logging.getLogger(__name__)


class BuildingCompletor:
"""Logic of building completion.
Some points were too isolated for BuildingValidator to consider them.
We will update their classification based on their probability as well as their surrounding:
- We select points that have p>=0.5
- We perform vertical (XY) clustering including these points as well as confirmed buildings.
- In the resulting groups, if there are some confirmed buildings, previously isolated points are
considered to be parts of the same building and their class is updated accordingly.
"""

def __init__(
self,
min_building_proba: float = 0.75,
min_building_proba_relaxation_if_bd_uni_overlay: float = 1.0,
cluster=None,
data_format=None,
):
self.cluster = cluster
self.min_building_proba = min_building_proba
self.min_building_proba_relaxation_if_bd_uni_overlay = (
min_building_proba_relaxation_if_bd_uni_overlay
)
self.data_format = data_format
self.codes = data_format.codes.building # easier access

def run(self, in_f: str, out_f: str):
"""Application.
Transform cloud at `in_f` following building completion logic, and save it to
`out_f`
Args:
in_f (str): path to input LAS file, output of BuildingValidator
out_f (str): path for saving updated LAS file.
Returns:
_type_: returns `out_f` for potential terminal piping.
"""
log.info(f"Applying Building Completion to file \n{in_f}")
log.info(
"Completion of building with relatively distant points that have high enough probability"
)
with TemporaryDirectory() as td:
temp_f = osp.join(td, osp.basename(in_f))
self.prepare(in_f, temp_f)
self.update(temp_f, out_f)
return out_f

def prepare(self, in_f: str, out_f: str):
f"""Prepare for building completion.
Identify candidates that were not clustered together by the BuildingValidator, but that
have high enough probability. Then, cluster them together with previously confirmed buildings.
Cluster parameters are relaxed (2D, with high tolerance).
If a cluster contains some confirmed points, the others are considered to belong to the same building
and they will be confirmed as well.
Args:
in_f (str): input LAS
out_f (str): output, prepared LAS with a new `{self.data_format.las_dimensions.ClusterID_isolated_plus_confirmed}`
dimension.
"""
pipeline = pdal.Pipeline()
pipeline |= pdal.Reader(
in_f,
type="readers.las",
# nosrs=True,
# override_srs=self.data_format.crs_prefix + str(self.data_format.crs),
)
candidates = (
f"({self.data_format.las_dimensions.candidate_buildings_flag} == 1)"
)

where_not_clustered = (
f"{self.data_format.las_dimensions.ClusterID_candidate_building} == 0"
)

# P above threshold
p_heq_threshold = f"(building>={self.min_building_proba})"

# P above relaxed threshold when under BDUni
under_bd_uni = f"({self.data_format.las_dimensions.uni_db_overlay} > 0)"
p_heq_relaxed_threshold = f"(building>={self.min_building_proba * self.min_building_proba_relaxation_if_bd_uni_overlay})"
p_heq_threshold_under_bd_uni = f"({p_heq_relaxed_threshold} && {under_bd_uni})"

# Candidates that where clustered by BuildingValidator but have high enough probability.
not_clustered_but_with_high_p = f"{candidates} && {where_not_clustered} && ({p_heq_threshold} || {p_heq_threshold_under_bd_uni})"
confirmed_buildings = (
f"Classification == {self.data_format.codes.building.final.building}"
)

where = f"{not_clustered_but_with_high_p} || {confirmed_buildings}"
pipeline |= pdal.Filter.cluster(
min_points=self.cluster.min_points,
tolerance=self.cluster.tolerance,
is3d=self.cluster.is3d,
where=where,
)
# Always move and reset ClusterID to avoid conflict with later tasks.
pipeline |= pdal.Filter.ferry(
dimensions=f"{self.data_format.las_dimensions.cluster_id}=>{self.data_format.las_dimensions.ClusterID_isolated_plus_confirmed}"
)
pipeline |= pdal.Filter.assign(
value=f"{self.data_format.las_dimensions.cluster_id} = 0"
)
pipeline |= pdal.Writer(
type="writers.las", filename=out_f, forward="all", extra_dims="all"
)
os.makedirs(osp.dirname(out_f), exist_ok=True)
pipeline.execute()

def update(self, prepared_f: str, out_f: str):
"""
Args:
in_f (str): input, prepared LAS
out_f (str): output LAS, with updated Classification dimension.
"""
las = laspy.read(prepared_f)
_clf = self.data_format.las_dimensions.classification
_cid = self.data_format.las_dimensions.ClusterID_isolated_plus_confirmed
# 2) Decide at the group-level
split_idx = split_idx_by_dim(las[_cid])
# Isolated/confirmed groups have a cluster index > 0
split_idx = split_idx[1:]
for pts_idx in tqdm(
split_idx, desc="Complete buildings with isolated points", unit="grp"
):
pts = las.points[pts_idx]
if self.codes.final.building in pts[_clf]:
las[_clf][pts_idx] = self.codes.final.building
os.makedirs(osp.dirname(out_f), exist_ok=True)
las.write(out_f)
Loading

0 comments on commit b816349

Please sign in to comment.