├── .github
└── workflows
│ └── analyze.yml
├── .gitignore
├── Dockerfile
├── LICENSE.md
├── README.md
├── docker-compose.yml
├── examples
├── mineral-extract-sites-detection
│ ├── README.md
│ ├── config_det.yaml
│ ├── config_trne.yaml
│ ├── data
│ │ ├── AoI
│ │ │ ├── AoI_2020.cpg
│ │ │ ├── AoI_2020.dbf
│ │ │ ├── AoI_2020.prj
│ │ │ ├── AoI_2020.shp
│ │ │ └── AoI_2020.shx
│ │ ├── FP
│ │ │ └── FP_list.gpkg
│ │ └── labels
│ │ │ ├── FP_list.gpkg
│ │ │ ├── tlm-hr-trn-topo.dbf
│ │ │ ├── tlm-hr-trn-topo.prj
│ │ │ ├── tlm-hr-trn-topo.shp
│ │ │ └── tlm-hr-trn-topo.shx
│ ├── detectron2_config_dqry.yaml
│ ├── filter_detections.py
│ ├── get_dem.sh
│ └── prepare_data.py
├── road-surface-classification
│ ├── README.md
│ ├── config_rs.yaml
│ ├── data
│ │ ├── AOI
│ │ │ ├── AOI.dbf
│ │ │ ├── AOI.prj
│ │ │ ├── AOI.qmd
│ │ │ ├── AOI.shp
│ │ │ ├── AOI.shx
│ │ │ ├── training_AOI.dbf
│ │ │ ├── training_AOI.prj
│ │ │ ├── training_AOI.shp
│ │ │ └── training_AOI.shx
│ │ ├── roads_parameters.xlsx
│ │ └── swissTLM3D
│ │ │ ├── forests.cpg
│ │ │ ├── forests.dbf
│ │ │ ├── forests.prj
│ │ │ ├── forests.qix
│ │ │ ├── forests.qmd
│ │ │ ├── forests.shp
│ │ │ ├── forests.shx
│ │ │ ├── roads_lines.cpg
│ │ │ ├── roads_lines.dbf
│ │ │ ├── roads_lines.prj
│ │ │ ├── roads_lines.qix
│ │ │ ├── roads_lines.qmd
│ │ │ ├── roads_lines.shp
│ │ │ └── roads_lines.shx
│ ├── detectron2_config_3bands.yaml
│ ├── fct_misc.py
│ └── prepare_data.py
└── swimming-pool-detection
│ ├── GE
│ ├── README.md
│ ├── config_GE.yaml
│ ├── data
│ │ └── OK_z18_tile_IDs.csv
│ ├── detectron2_config_GE.yaml
│ └── prepare_data.py
│ └── NE
│ ├── README.md
│ ├── config_NE.yaml
│ ├── data
│ ├── Ground_truth_sectors.cpg
│ ├── Ground_truth_sectors.dbf
│ ├── Ground_truth_sectors.prj
│ ├── Ground_truth_sectors.shp
│ ├── Ground_truth_sectors.shx
│ ├── Ground_truth_swimming_pools.cpg
│ ├── Ground_truth_swimming_pools.dbf
│ ├── Ground_truth_swimming_pools.prj
│ ├── Ground_truth_swimming_pools.shp
│ ├── Ground_truth_swimming_pools.shx
│ ├── Other_sector.cpg
│ ├── Other_sector.dbf
│ ├── Other_sector.prj
│ ├── Other_sector.shp
│ ├── Other_sector.shx
│ ├── Other_swimming_pools.cpg
│ ├── Other_swimming_pools.dbf
│ ├── Other_swimming_pools.prj
│ ├── Other_swimming_pools.shp
│ ├── Other_swimming_pools.shx
│ └── README.md
│ ├── detectron2_config_NE.yaml
│ └── prepare_data.py
├── helpers
├── COCO.py
├── FOLDER.py
├── MIL.py
├── WMS.py
├── XYZ.py
├── __init__.py
├── constants.py
├── detectron2.py
├── download_tiles.py
├── metrics.py
├── misc.py
└── split_tiles.py
├── requirements.in
├── requirements.txt
├── scripts
├── __init__.py
├── assess_detections.py
├── cli.py
├── generate_tilesets.py
├── make_detections.py
└── train_model.py
├── setup.py
└── sonar-project.properties
/.github/workflows/analyze.yml:
--------------------------------------------------------------------------------
1 | name: Analyze
2 |
3 | on:
4 | push:
5 | branches:
6 | - '*'
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 |
10 | jobs:
11 | analyze:
12 | name: Analyze
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v2
16 | with:
17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
18 | - uses: sonarsource/sonarqube-scan-action@master
19 | env:
20 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
21 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
22 | # If you wish to fail your job when the Quality Gate is red, uncomment the
23 | # following lines. This would typically be used to fail a deployment.
24 | # We do not recommend to use this in a pull request. Prefer using pull request
25 | # decoration instead.
26 | # - uses: sonarsource/sonarqube-quality-gate-action@master
27 | # timeout-minutes: 5
28 | # env:
29 | # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | output*
2 | DEM*
3 | cache
4 |
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | .vscode/
27 | wheels/
28 | pip-wheel-metadata/
29 | share/python-wheels/
30 | *.egg-info/
31 | .installed.cfg
32 | *.egg
33 | MANIFEST
34 |
35 | # PyInstaller
36 | # Usually these files are written by a python script from a template
37 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
38 | *.manifest
39 | *.spec
40 |
41 | # Installer logs
42 | pip-log.txt
43 | pip-delete-this-directory.txt
44 |
45 | # Unit test / coverage reports
46 | htmlcov/
47 | .tox/
48 | .nox/
49 | .coverage
50 | .coverage.*
51 | .cache
52 | nosetests.xml
53 | coverage.xml
54 | *.cover
55 | *.py,cover
56 | .hypothesis/
57 | .pytest_cache/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | .python-version
91 |
92 | # pipenv
93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
96 | # install all needed dependencies.
97 | #Pipfile.lock
98 |
99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
100 | __pypackages__/
101 |
102 | # Celery stuff
103 | celerybeat-schedule
104 | celerybeat.pid
105 |
106 | # SageMath parsed files
107 | *.sage.py
108 |
109 | # Environments
110 | .env
111 | .venv
112 | env/
113 | venv/
114 | ENV/
115 | env.bak/
116 | venv.bak/
117 |
118 | # Spyder project settings
119 | .spyderproject
120 | .spyproject
121 |
122 | # Rope project settings
123 | .ropeproject
124 |
125 | # mkdocs documentation
126 | /site
127 |
128 | # mypy
129 | .mypy_cache/
130 | .dmypy.json
131 | dmypy.json
132 |
133 | # Pyre type checker
134 | .pyre/
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nvidia/cuda:11.3.1-runtime-ubuntu20.04
2 |
3 | # see https://superuser.com/a/1541135
4 | RUN chmod 1777 /tmp
5 |
6 | RUN apt update &&\
7 | apt upgrade -y &&\
8 | apt install -y libgl1 &&\
9 | apt install -y libglib2.0-0 &&\
10 | apt install -y gdal-bin &&\
11 | apt install -y wget &&\
12 | apt install -y python3-pip &&\
13 | apt install -y python-is-python3
14 |
15 | WORKDIR /app
16 |
17 | ADD requirements.txt .
18 | RUN pip install -r requirements.txt --no-cache-dir
19 |
20 | ADD helpers/*.py helpers/
21 | ADD scripts/*.py scripts/
22 |
23 | ADD setup.py .
24 | RUN pip install .
25 |
26 | USER 65534:65534
27 |
28 | ENV MPLCONFIGDIR /tmp
29 |
30 | ENTRYPOINT [""]
31 | CMD ["stdl-objdet", "-h"]
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 République et canton de Genève
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | stdl-objdet:
3 | build: .
4 | volumes:
5 | - ./examples:/app/examples
6 | deploy:
7 | resources:
8 | reservations:
9 | devices:
10 | - driver: nvidia
11 | count: 1
12 | capabilities: [gpu]
13 | command: /bin/bash
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/README.md:
--------------------------------------------------------------------------------
1 | # Example: detection of Mineral Extraction Sites
2 |
3 | A sample working setup is provided here, enabling the end-user to detect Mineral Extraction Sites (MES) in Switzerland over several years.
4 | It consists of the following elements:
5 |
6 | - ready-to-use configuration files:
7 | - `config_trne.yaml`;
8 | - `config_det.yaml`;
9 | - `detectron2_config_dqry.yaml`.
10 | - Input data in the `data` subfolder:
11 | - MES **labels** issued from the [swissTLM3D](https://www.swisstopo.admin.ch/fr/geodata/landscape/tlm3d.html) product, revised and synchronized with the 2020 [SWISSIMAGE](https://www.swisstopo.admin.ch/fr/geodata/images/ortho/swissimage10.html) orthophotos;
12 | - the delimitation of the **Area of Interest (AoI)**;
13 | - the Swiss DEM raster is too large to be saved on this platform but can be downloaded from this [link](https://github.com/lukasmartinelli/swissdem) using the [EPSG:4326](https://epsg.io/4326) coordinate reference system. The raster must be re-projected to [EPSG:2056](https://epsg.io/2056), renamed as `switzerland_dem_EPSG2056.tif` and located in the **DEM** subfolder. This procedure is managed by running the bash script `get_dem.sh`.
14 | - A data preparation script (`prepare_data.py`) producing the files to be used as input to the `generate_tilesets` stage.
15 | - A post-processing script (`filter_detections.py`) which filters detections according to their confidence score, altitude and area. The script also identifies and merges groups of nearby polygons.
16 |
17 | The workflow can be run end-to-end by issuing the following list of commands, from the root folder of this GitHub repository:
18 |
19 | ```
20 | $ sudo chown -R 65534:65534 examples
21 | $ docker compose run --rm -it stdl-objdet
22 | nobody@:/app# cd examples/mineral-extract-sites-detection/
23 | nobody@:/app# python prepare_data.py config_trne.yaml
24 | nobody@:/app# stdl-objdet generate_tilesets config_trne.yaml
25 | nobody@:/app# stdl-objdet train_model config_trne.yaml
26 | nobody@:/app# stdl-objdet make_detections config_trne.yaml
27 | nobody@:/app# stdl-objdet assess_detections config_trne.yaml
28 | nobody@:/app# python prepare_data.py config_det.yaml
29 | nobody@:/app# stdl-objdet generate_tilesets config_det.yaml
30 | nobody@:/app# stdl-objdet make_detections config_det.yaml
31 | nobody@:/app# bash get_dem.sh
32 | nobody@:/app# python filter_detections.py config_det.yaml
33 | nobody@:/app# exit
34 | $ sudo chmod -R a+w examples
35 | ```
36 |
37 | We strongly encourage the end-user to review the provided `config_trne.yaml` and `config_det.yaml` files as well as the various output files, a list of which is printed by each script before exiting.
38 |
39 | The model is trained on the 2020 [SWISSIMAGE](https://www.swisstopo.admin.ch/fr/geodata/images/ortho/swissimage10.html) mosaic. Inference can be performed on SWISSIMAGE mosaics of the product [SWISSIMAGE time travel](https://map.geo.admin.ch/?lang=en&topic=swisstopo&bgLayer=ch.swisstopo.pixelkarte-farbe&zoom=0&layers_timestamp=2004,2004,&layers=ch.swisstopo.swissimage-product,ch.swisstopo.swissimage-product.metadata,ch.swisstopo.images-swissimage-dop10.metadata&E=2594025.91&N=1221065.68&layers_opacity=1,0.7,1&time=2004&layers_visibility=true,true,false) by changing the year in `config_det.yaml`. It should be noted that the model has been trained on RGB images and might not perform as well on black and white images.
40 |
41 | For more information about this project, see [this repository](https://github.com/swiss-territorial-data-lab/proj-dqry).
42 |
43 | ## Disclaimer
44 |
45 | Depending on the end purpose, we strongly recommend users not to take for granted the detections obtained through this code. Indeed, results can exhibit false positives and false negatives, as is the case in all machine learning-based approaches.
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/config_det.yaml:
--------------------------------------------------------------------------------
1 | ###################################
2 | ####### Inference detection #######
3 | # Automatic detection of Quarries and Mineral Extraction Sites (MES) in images
4 |
5 | # 1-Produce tile geometries based on the AoI extent and zoom level
6 | prepare_data.py:
7 | srs: "EPSG:2056" # Projection of the input file
8 | datasets:
9 | shapefile: ./data/AoI/AoI_2020.shp
10 | output_folder: ./output/det/
11 | zoom_level: 16
12 |
13 | # 2-Fetch of tiles (online server) and split into 3 datasets: train, test, validation
14 | generate_tilesets.py:
15 | debug_mode:
16 | enable: False # sample of tiles
17 | nb_tiles_max: 5000
18 | working_directory: output
19 | datasets:
20 | aoi_tiles: det/tiles.geojson
21 | image_source:
22 | type: XYZ # supported values: 1. MIL = Map Image Layer 2. WMS 3. XYZ 4. FOLDER
23 | location: https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/2020/3857/{z}/{x}/{y}.jpeg
24 | output_folder: det/
25 | tile_size: 256 # per side, in pixels
26 | overwrite: False
27 | n_jobs: 10
28 | COCO_metadata:
29 | year: 2021
30 | version: 1.0
31 | description: Swiss Image Hinterground w/ Quarries and Mineral Exploitation Sites detection
32 | contributor: swisstopo
33 | url: https://swisstopo.ch
34 | license:
35 | name: Unknown
36 | url:
37 | categories_file: trne/category_ids.json
38 |
39 | # 3-Object detection by inference with the optimised trained model
40 | make_detections.py:
41 | working_directory: ./output/det/
42 | log_subfolder: logs
43 | sample_tagged_img_subfolder: sample_tagged_images
44 | COCO_files: # relative paths, w/ respect to the working_folder
45 | oth: COCO_oth.json
46 | detectron2_config_file: ../../detectron2_config_dqry.yaml # path relative to the working_folder
47 | model_weights:
48 | pth_file: ../trne/logs/model_final.pth # trained model minimising the validation loss curve, monitor the training process via tensorboard (tensorboard --logdir )
49 | image_metadata_json: img_metadata.json
50 | rdp_simplification: # rdp = Ramer-Douglas-Peucker
51 | enabled: True
52 | epsilon: 2.0 # cf. https://rdp.readthedocs.io/en/latest/
53 | score_lower_threshold: 0.3
54 | remove_det_overlap: False # if several detections overlap (IoU > 0.5), only the one with the highest confidence score is retained. Not recommended for use with a single class.
55 |
56 | # 4-Filtering and merging detection polygons
57 | filter_detections.py:
58 | year: 2020
59 | detections: ./output/det/oth_detections_at_0dot3_threshold.gpkg
60 | shapefile: ./data/AoI/AoI_2020.shp
61 | dem: ./data/DEM/switzerland_dem_EPSG2056.tif
62 | elevation: 1200.0 # m, altitude threshold
63 | score: 0.95 # detection score (from 0 to 1) provided by detectron2
64 | distance: 10 # m, distance use as a buffer to merge close polygons (likely to belong to the same object) together
65 | area: 5000.0 # m2, area threshold under which polygons are discarded
66 | output: ./output/det/oth_detections_at_0dot3_threshold_year-{year}_score-{score}_area-{area}_elevation-{elevation}_distance-{distance}.geojson
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/config_trne.yaml:
--------------------------------------------------------------------------------
1 | # Produce tile geometries based on the AoI extent and zoom level
2 | prepare_data.py:
3 | srs: EPSG:2056
4 | datasets:
5 | shapefile: ./data/labels/tlm-hr-trn-topo.shp # GT labels
6 | # fp_shapefile: ./data/FP/ # FP labels
7 | # empty_tiles_aoi: ./data/AoI/ # AOI in which additional empty tiles can be selected. Only one 'empty_tiles' option can be selected
8 | # empty_tiles_year: 2020 # If "empty_tiles_aoi" selected then provide a year. Choice: (1) numeric (i.e. 2020), (2) [year1, year2] (random selection of a year within a given year range)
9 | # empty_tiles_shp: .data/empty_tiles/ # Provided shapefile of selected empty tiles. Only one 'empty_tiles' option can be selected
10 | output_folder: ./output/trne/
11 | zoom_level: 16
12 |
13 | # Fetch of tiles and split into 3 datasets: train, test, validation
14 | generate_tilesets.py:
15 | debug_mode:
16 | enable: False # sample of tiles
17 | nb_tiles_max: 5000
18 | working_directory: output
19 | datasets:
20 | aoi_tiles: trne/tiles.geojson
21 | ground_truth_labels: trne/labels.geojson
22 | # add_fp_labels:
23 | # fp_labels: trne/FP.geojson
24 | # frac_trn: 0.7 # fraction of fp tiles to add to the trn dataset, then the remaining tiles will be split in 2 and added to tst and val datasets
25 | image_source:
26 | type: XYZ # supported values: 1. MIL = Map Image Layer 2. WMS 3. XYZ 4. FOLDER
27 | year: 2020 # supported values: 1. multi-year (tiles of different year), 2. (i.e. 2020)
28 | location: https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/{year}/3857/{z}/{x}/{y}.jpeg
29 | # add_empty_tiles: # add empty tiles to datasets
30 | # tiles_frac: 0.5 # fraction (relative to the number of tiles intersecting labels) of empty tiles to add
31 | # frac_trn: 0.7 # fraction of empty tiles to add to the trn dataset, then the remaining tiles will be split in 2 and added to tst and val datasets
32 | # keep_oth_tiles: False # keep tiles in oth dataset not intersecting oth labels
33 | output_folder: trne/
34 | tile_size: 256 # per side, in pixels
35 | seed: 42
36 | overwrite: True
37 | n_jobs: 10
38 | COCO_metadata:
39 | year: 2021
40 | version: 1.0
41 | description: Swiss Image Hinterground w/ Quarries and Mineral Exploitation Sites detection
42 | contributor: swisstopo
43 | url: https://swisstopo.ch
44 | license:
45 | name: unknown
46 | url: unknown
47 |
48 | # Train the model with the detectron2 algorithm
49 | train_model.py:
50 | working_directory: ./output/trne/
51 | log_subfolder: logs
52 | sample_tagged_img_subfolder: sample_tagged_images
53 | COCO_files: # relative paths, w/ respect to the working_folder
54 | trn: COCO_trn.json
55 | val: COCO_val.json
56 | tst: COCO_tst.json
57 | detectron2_config_file: ../../detectron2_config_dqry.yaml # path relative to the working_folder
58 | model_weights:
59 | model_zoo_checkpoint_url: COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml
60 |
61 | # Object detection with the optimised trained model
62 | make_detections.py:
63 | working_directory: ./output/trne/
64 | log_subfolder: logs
65 | sample_tagged_img_subfolder: sample_tagged_images
66 | COCO_files: # relative paths, w/ respect to the working_folder
67 | trn: COCO_trn.json
68 | val: COCO_val.json
69 | tst: COCO_tst.json
70 | detectron2_config_file: ../../detectron2_config_dqry.yaml # path relative to the working_folder
71 | model_weights:
72 | pth_file: ./logs/model_0002999.pth # trained model minimising the validation loss curve, monitor the training process via tensorboard (tensorboard --logdir )
73 | image_metadata_json: img_metadata.json
74 | rdp_simplification: # rdp = Ramer-Douglas-Peucker
75 | enabled: True
76 | epsilon: 2.0 # cf. https://rdp.readthedocs.io/en/latest/
77 | score_lower_threshold: 0.05
78 | remove_det_overlap: False # if several detections overlap (IoU > 0.5), only the one with the highest confidence score is retained. Not recommended for use with a single class.
79 |
80 | # Evaluate the quality of the detections for the different datasets by calculating metrics
81 | assess_detections.py:
82 | working_directory: ./output/trne/
83 | datasets:
84 | ground_truth_labels: labels.geojson
85 | image_metadata_json: img_metadata.json
86 | split_aoi_tiles: split_aoi_tiles.geojson # aoi = Area of Interest
87 | categories: category_ids.json
88 | detections:
89 | trn: trn_detections_at_0dot05_threshold.gpkg
90 | val: val_detections_at_0dot05_threshold.gpkg
91 | tst: tst_detections_at_0dot05_threshold.gpkg
92 | output_folder: .
93 | iou_threshold: 0.1
94 | area_threshold: 50 # area under which the polygons are discarded from assessment
95 | metrics_method: macro-average # 1: macro-average ; 3: macro-weighted-average ; 2: micro-average
96 |
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.cpg:
--------------------------------------------------------------------------------
1 | UTF-8
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.dbf:
--------------------------------------------------------------------------------
1 | { A id N
2 |
1
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.shp
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/AoI/AoI_2020.shx
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/FP/FP_list.gpkg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/FP/FP_list.gpkg
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/labels/FP_list.gpkg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/labels/FP_list.gpkg
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.dbf:
--------------------------------------------------------------------------------
1 | _
2 | A W Feature ID N
3 |
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** ********** **********
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.shp
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/mineral-extract-sites-detection/data/labels/tlm-hr-trn-topo.shx
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/detectron2_config_dqry.yaml:
--------------------------------------------------------------------------------
1 | CUDNN_BENCHMARK: false
2 | DATALOADER:
3 | ASPECT_RATIO_GROUPING: true
4 | FILTER_EMPTY_ANNOTATIONS: false
5 | NUM_WORKERS: 4
6 | REPEAT_THRESHOLD: 0.0
7 | SAMPLER_TRAIN: TrainingSampler
8 | DATASETS:
9 | PRECOMPUTED_PROPOSAL_TOPK_TEST: 1000
10 | PRECOMPUTED_PROPOSAL_TOPK_TRAIN: 2000
11 | PROPOSAL_FILES_TEST: []
12 | PROPOSAL_FILES_TRAIN: []
13 | TEST:
14 | - val_dataset
15 | TRAIN:
16 | - trn_dataset
17 | GLOBAL:
18 | HACK: 1.0
19 | INPUT:
20 | CROP:
21 | ENABLED: false
22 | SIZE:
23 | - 0.9
24 | - 0.9
25 | TYPE: relative_range
26 | FORMAT: RGB
27 | MASK_FORMAT: polygon
28 | MAX_SIZE_TEST: 1333
29 | MAX_SIZE_TRAIN: 1333
30 | MIN_SIZE_TEST: 800
31 | MIN_SIZE_TRAIN:
32 | - 640
33 | - 672
34 | - 704
35 | - 736
36 | - 768
37 | - 800
38 | MIN_SIZE_TRAIN_SAMPLING: choice
39 | MODEL:
40 | ANCHOR_GENERATOR:
41 | ANGLES:
42 | - - -90
43 | - 0
44 | - 90
45 | ASPECT_RATIOS:
46 | - - 0.5
47 | - 1.0
48 | - 2.0
49 | NAME: DefaultAnchorGenerator
50 | OFFSET: 0.0
51 | SIZES:
52 | - - 32
53 | - - 64
54 | - - 128
55 | - - 256
56 | - - 512
57 | BACKBONE:
58 | FREEZE_AT: 2
59 | NAME: build_resnet_fpn_backbone
60 | DEVICE: cuda
61 | FPN:
62 | FUSE_TYPE: sum
63 | IN_FEATURES:
64 | - res2
65 | - res3
66 | - res4
67 | - res5
68 | NORM: ''
69 | OUT_CHANNELS: 256
70 | KEYPOINT_ON: false
71 | LOAD_PROPOSALS: false
72 | MASK_ON: true
73 | META_ARCHITECTURE: GeneralizedRCNN
74 | PANOPTIC_FPN:
75 | COMBINE:
76 | ENABLED: true
77 | INSTANCES_CONFIDENCE_THRESH: 0.5
78 | OVERLAP_THRESH: 0.5
79 | STUFF_AREA_LIMIT: 4096
80 | INSTANCE_LOSS_WEIGHT: 1.0
81 | PIXEL_MEAN:
82 | - 103.53
83 | - 116.28
84 | - 123.675
85 | PIXEL_STD:
86 | - 1.0
87 | - 1.0
88 | - 1.0
89 | PROPOSAL_GENERATOR:
90 | MIN_SIZE: 0
91 | NAME: RPN
92 | RESNETS:
93 | DEFORM_MODULATED: false
94 | DEFORM_NUM_GROUPS: 1
95 | DEFORM_ON_PER_STAGE:
96 | - false
97 | - false
98 | - false
99 | - false
100 | DEPTH: 50
101 | NORM: FrozenBN
102 | NUM_GROUPS: 1
103 | OUT_FEATURES:
104 | - res2
105 | - res3
106 | - res4
107 | - res5
108 | RES2_OUT_CHANNELS: 256
109 | RES5_DILATION: 1
110 | STEM_OUT_CHANNELS: 64
111 | STRIDE_IN_1X1: true
112 | WIDTH_PER_GROUP: 64
113 | RETINANET:
114 | BBOX_REG_WEIGHTS:
115 | - 1.0
116 | - 1.0
117 | - 1.0
118 | - 1.0
119 | FOCAL_LOSS_ALPHA: 0.25
120 | FOCAL_LOSS_GAMMA: 2.0
121 | IN_FEATURES:
122 | - p3
123 | - p4
124 | - p5
125 | - p6
126 | - p7
127 | IOU_LABELS:
128 | - 0
129 | - -1
130 | - 1
131 | IOU_THRESHOLDS:
132 | - 0.4
133 | - 0.5
134 | NMS_THRESH_TEST: 0.5
135 | NUM_CLASSES: 80
136 | NUM_CONVS: 4
137 | PRIOR_PROB: 0.01
138 | SCORE_THRESH_TEST: 0.05
139 | SMOOTH_L1_LOSS_BETA: 0.1
140 | TOPK_CANDIDATES_TEST: 1000
141 | ROI_BOX_CASCADE_HEAD:
142 | BBOX_REG_WEIGHTS:
143 | - - 10.0
144 | - 10.0
145 | - 5.0
146 | - 5.0
147 | - - 20.0
148 | - 20.0
149 | - 10.0
150 | - 10.0
151 | - - 30.0
152 | - 30.0
153 | - 15.0
154 | - 15.0
155 | IOUS:
156 | - 0.5
157 | - 0.6
158 | - 0.7
159 | ROI_BOX_HEAD:
160 | BBOX_REG_WEIGHTS:
161 | - 10.0
162 | - 10.0
163 | - 5.0
164 | - 5.0
165 | CLS_AGNOSTIC_BBOX_REG: false
166 | CONV_DIM: 256
167 | FC_DIM: 1024
168 | NAME: FastRCNNConvFCHead
169 | NORM: ''
170 | NUM_CONV: 0
171 | NUM_FC: 2
172 | POOLER_RESOLUTION: 7
173 | POOLER_SAMPLING_RATIO: 0
174 | POOLER_TYPE: ROIAlignV2
175 | SMOOTH_L1_BETA: 0.0
176 | TRAIN_ON_PRED_BOXES: false
177 | ROI_HEADS:
178 | BATCH_SIZE_PER_IMAGE: 1024
179 | IN_FEATURES:
180 | - p2
181 | - p3
182 | - p4
183 | - p5
184 | IOU_LABELS:
185 | - 0
186 | - 1
187 | IOU_THRESHOLDS:
188 | - 0.5
189 | NAME: StandardROIHeads
190 | NMS_THRESH_TEST: 0.5
191 | NUM_CLASSES: 1
192 | POSITIVE_FRACTION: 0.25
193 | PROPOSAL_APPEND_GT: true
194 | SCORE_THRESH_TEST: 0.05
195 | ROI_KEYPOINT_HEAD:
196 | CONV_DIMS:
197 | - 512
198 | - 512
199 | - 512
200 | - 512
201 | - 512
202 | - 512
203 | - 512
204 | - 512
205 | LOSS_WEIGHT: 1.0
206 | MIN_KEYPOINTS_PER_IMAGE: 1
207 | NAME: KRCNNConvDeconvUpsampleHead
208 | NORMALIZE_LOSS_BY_VISIBLE_KEYPOINTS: true
209 | NUM_KEYPOINTS: 17
210 | POOLER_RESOLUTION: 14
211 | POOLER_SAMPLING_RATIO: 0
212 | POOLER_TYPE: ROIAlignV2
213 | ROI_MASK_HEAD:
214 | CLS_AGNOSTIC_MASK: false
215 | CONV_DIM: 256
216 | NAME: MaskRCNNConvUpsampleHead
217 | NORM: ''
218 | NUM_CONV: 4
219 | POOLER_RESOLUTION: 14
220 | POOLER_SAMPLING_RATIO: 0
221 | POOLER_TYPE: ROIAlignV2
222 | RPN:
223 | BATCH_SIZE_PER_IMAGE: 256
224 | BBOX_REG_WEIGHTS:
225 | - 1.0
226 | - 1.0
227 | - 1.0
228 | - 1.0
229 | BOUNDARY_THRESH: -1
230 | HEAD_NAME: StandardRPNHead
231 | IN_FEATURES:
232 | - p2
233 | - p3
234 | - p4
235 | - p5
236 | - p6
237 | IOU_LABELS:
238 | - 0
239 | - -1
240 | - 1
241 | IOU_THRESHOLDS:
242 | - 0.3
243 | - 0.7
244 | LOSS_WEIGHT: 1.0
245 | NMS_THRESH: 0.7
246 | POSITIVE_FRACTION: 0.5
247 | POST_NMS_TOPK_TEST: 1000
248 | POST_NMS_TOPK_TRAIN: 1000
249 | PRE_NMS_TOPK_TEST: 1000
250 | PRE_NMS_TOPK_TRAIN: 2000
251 | SMOOTH_L1_BETA: 0.0
252 | SEM_SEG_HEAD:
253 | COMMON_STRIDE: 4
254 | CONVS_DIM: 128
255 | IGNORE_VALUE: 255
256 | IN_FEATURES:
257 | - p2
258 | - p3
259 | - p4
260 | - p5
261 | LOSS_WEIGHT: 1.0
262 | NAME: SemSegFPNHead
263 | NORM: GN
264 | NUM_CLASSES: 54
265 | WEIGHTS: https://dl.fbaipublicfiles.com/detectron2/COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x/137260431/model_final_a54504.pkl
266 | OUTPUT_DIR: logs
267 | SEED: 42
268 | SOLVER:
269 | BASE_LR: 0.005
270 | BIAS_LR_FACTOR: 1.0
271 | CHECKPOINT_PERIOD: 1000
272 | CLIP_GRADIENTS:
273 | CLIP_TYPE: value
274 | CLIP_VALUE: 1.0
275 | ENABLED: false
276 | NORM_TYPE: 2.0
277 | GAMMA: 0.8
278 | IMS_PER_BATCH: 2
279 | LR_SCHEDULER_NAME: WarmupMultiStepLR
280 | MAX_ITER: 3000
281 | MOMENTUM: 0.9
282 | NESTEROV: false
283 | STEPS:
284 | - 500
285 | - 1000
286 | - 1500
287 | - 2000
288 | - 2500
289 | WARMUP_FACTOR: 0.001
290 | WARMUP_ITERS: 200
291 | WARMUP_METHOD: linear
292 | WEIGHT_DECAY: 0.0001
293 | WEIGHT_DECAY_BIAS: 0.0001
294 | WEIGHT_DECAY_NORM: 0.0
295 | TEST:
296 | AUG:
297 | ENABLED: false
298 | FLIP: true
299 | MAX_SIZE: 4000
300 | MIN_SIZES:
301 | - 400
302 | - 500
303 | - 600
304 | - 700
305 | - 800
306 | - 900
307 | - 1000
308 | - 1100
309 | - 1200
310 | DETECTIONS_PER_IMAGE: 100
311 | EVAL_PERIOD: 200
312 | EXPECTED_RESULTS: []
313 | KEYPOINT_OKS_SIGMAS: []
314 | PRECISE_BN:
315 | ENABLED: false
316 | NUM_ITER: 200
317 | VERSION: 2
318 | VIS_PERIOD: 0
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/filter_detections.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import time
7 | import argparse
8 | import yaml
9 |
10 | import geopandas as gpd
11 | import pandas as pd
12 | import rasterio
13 | from sklearn.cluster import KMeans
14 |
15 | sys.path.insert(0, '.')
16 | from helpers import misc
17 | from helpers.constants import DONE_MSG
18 |
19 | from loguru import logger
20 | logger = misc.format_logger(logger)
21 |
22 |
23 | if __name__ == "__main__":
24 |
25 | # Chronometer
26 | tic = time.time()
27 | logger.info('Starting...')
28 |
29 | # Argument and parameter specification
30 | parser = argparse.ArgumentParser(description="The script filters the detection of potential Mineral Extraction Sites obtained with the object-detector scripts")
31 | parser.add_argument('config_file', type=str, help='input geojson path')
32 | args = parser.parse_args()
33 |
34 | logger.info(f"Using {args.config_file} as config file.")
35 |
36 | with open(args.config_file) as fp:
37 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
38 |
39 | # Load input parameters
40 | YEAR = cfg['year']
41 | DETECTIONS = cfg['detections']
42 | SHPFILE = cfg['shapefile']
43 | DEM = cfg['dem']
44 | SCORE = cfg['score']
45 | AREA = cfg['area']
46 | ELEVATION = cfg['elevation']
47 | DISTANCE = cfg['distance']
48 | OUTPUT = cfg['output']
49 |
50 | written_files = []
51 |
52 | # Convert input detection to a geo dataframe
53 | aoi = gpd.read_file(SHPFILE)
54 | aoi = aoi.to_crs(epsg=2056)
55 |
56 | detections = gpd.read_file(DETECTIONS)
57 | detections = detections.to_crs(2056)
58 | total = len(detections)
59 | logger.info(f"{total} input shapes")
60 |
61 | # Discard polygons detected above the threshold elevalation and 0 m
62 | r = rasterio.open(DEM)
63 | row, col = r.index(detections.centroid.x, detections.centroid.y)
64 | values = r.read(1)[row, col]
65 | detections['elevation'] = values
66 | detections = detections[detections.elevation < ELEVATION]
67 | row, col = r.index(detections.centroid.x, detections.centroid.y)
68 | values = r.read(1)[row, col]
69 | detections['elevation'] = values
70 |
71 | detections = detections[detections.elevation != 0]
72 | te = len(detections)
73 | logger.info(f"{total - te} detections were removed by elevation threshold: {ELEVATION} m")
74 |
75 | # Centroid of every detection polygon
76 | centroids = gpd.GeoDataFrame()
77 | centroids.geometry = detections.representative_point()
78 |
79 | # KMeans Unsupervised Learning
80 | centroids = pd.DataFrame({'x': centroids.geometry.x, 'y': centroids.geometry.y})
81 | k = int((len(detections)/3) + 1)
82 | cluster = KMeans(n_clusters=k, algorithm='auto', random_state=1)
83 | model = cluster.fit(centroids)
84 | labels = model.predict(centroids)
85 | logger.info(f"KMeans algorithm computed with k = {k}")
86 |
87 | # Dissolve and aggregate (keep the max value of aggregate attributes)
88 | detections['cluster'] = labels
89 |
90 | detections = detections.dissolve(by='cluster', aggfunc='max')
91 | total = len(detections)
92 |
93 | # Filter dataframe by score value
94 | detections = detections[detections['score'] > SCORE]
95 | sc = len(detections)
96 | logger.info(f"{total - sc} detections were removed by score threshold: {SCORE}")
97 |
98 | # Clip detection to AoI
99 | detections = gpd.clip(detections, aoi)
100 |
101 | # Merge close labels using buffer and unions
102 | detections_merge = gpd.GeoDataFrame()
103 | detections_merge = detections.buffer(+DISTANCE, resolution=2)
104 | detections_merge = detections_merge.geometry.unary_union
105 | detections_merge = gpd.GeoDataFrame(geometry=[detections_merge], crs=detections.crs)
106 | detections_merge = detections_merge.explode(index_parts=True).reset_index(drop=True)
107 | detections_merge = detections_merge.buffer(-DISTANCE, resolution=2)
108 |
109 | td = len(detections_merge)
110 | logger.info(f"{td} clustered detections remains after shape union (distance {DISTANCE})")
111 |
112 | # Discard polygons with area under the threshold
113 | detections_merge = detections_merge[detections_merge.area > AREA]
114 | ta = len(detections_merge)
115 | logger.info(f"{td - ta} detections were removed to after union (distance {AREA})")
116 |
117 | # Preparation of a geo df
118 | data = {'id': detections_merge.index,'area': detections_merge.area, 'centroid_x': detections_merge.centroid.x, 'centroid_y': detections_merge.centroid.y, 'geometry': detections_merge}
119 | geo_tmp = gpd.GeoDataFrame(data, crs=detections.crs)
120 |
121 | # Get the averaged detection score of the merged polygons
122 | intersection = gpd.sjoin(geo_tmp, detections, how='inner')
123 | intersection['id'] = intersection.index
124 | score_final = intersection.groupby(['id']).mean(numeric_only=True)
125 |
126 | # Formatting the final geo df
127 | data = {'id_feature': detections_merge.index,'score': score_final['score'] , 'area': detections_merge.area, 'centroid_x': detections_merge.centroid.x, 'centroid_y': detections_merge.centroid.y, 'geometry': detections_merge}
128 | detections_final = gpd.GeoDataFrame(data, crs=detections.crs)
129 | logger.info(f"{len(detections_final)} detections remaining after filtering")
130 |
131 | # Formatting the output name of the filtered detection
132 | feature = OUTPUT.replace('{score}', str(SCORE)).replace('0.', '0dot') \
133 | .replace('{year}', str(int(YEAR)))\
134 | .replace('{area}', str(int(AREA)))\
135 | .replace('{elevation}', str(int(ELEVATION))) \
136 | .replace('{distance}', str(int(DISTANCE)))
137 | detections_final.to_file(feature, driver='GeoJSON')
138 |
139 | written_files.append(feature)
140 | logger.success(f"{DONE_MSG} A file was written: {feature}")
141 |
142 | logger.info("The following files were written. Let's check them out!")
143 | for written_file in written_files:
144 | logger.info(written_file)
145 |
146 | # Stop chronometer
147 | toc = time.time()
148 | logger.info(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
149 |
150 | sys.stderr.flush()
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/get_dem.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | mkdir -p ./data/DEM/
4 | wget https://github.com/lukasmartinelli/swissdem/releases/download/v1.0/switzerland_dem.tif -O ./data/DEM/switzerland_dem.tif
5 | gdalwarp -t_srs "EPSG:2056" ./data/DEM/switzerland_dem.tif ./data/DEM/switzerland_dem_EPSG2056.tif
6 | rm ./data/DEM/switzerland_dem.tif
--------------------------------------------------------------------------------
/examples/mineral-extract-sites-detection/prepare_data.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import time
7 | import argparse
8 | import yaml
9 | import re
10 |
11 | import geopandas as gpd
12 | import morecantile
13 | import numpy as np
14 | import pandas as pd
15 | from shapely.geometry import Polygon
16 |
17 | sys.path.insert(0, '../..')
18 | from helpers.misc import format_logger
19 | from helpers.constants import DONE_MSG
20 |
21 | from loguru import logger
22 | logger = format_logger(logger)
23 |
24 |
25 | def add_tile_id(row):
26 | """Attribute tile id
27 |
28 | Args:
29 | row (DataFrame): row of a given df
30 |
31 | Returns:
32 | DataFrame: row with addition 'id' column
33 | """
34 |
35 | re_search = re.search('(x=(?P\d*), y=(?P\d*), z=(?P\d*))', row.title)
36 | if 'year' in row.keys():
37 | row['id'] = f"({row.year}, {re_search.group('x')}, {re_search.group('y')}, {re_search.group('z')})"
38 | else:
39 | row['id'] = f"({re_search.group('x')}, {re_search.group('y')}, {re_search.group('z')})"
40 |
41 | return row
42 |
43 |
44 | def aoi_tiling(gdf, tms='WebMercatorQuad'):
45 | """Tiling of an AoI
46 |
47 | Args:
48 | gdf (GeoDataFrame): gdf containing all the bbox boundary coordinates
49 |
50 | Returns:
51 | Geodataframe: gdf containing the tiles shape of the bbox of the AoI
52 | """
53 |
54 | # Grid definition
55 | tms = morecantile.tms.get(tms) # epsg:3857
56 |
57 | tiles_all = []
58 | for boundary in gdf.itertuples():
59 | coords = (boundary.minx, boundary.miny, boundary.maxx, boundary.maxy)
60 | tiles = gpd.GeoDataFrame.from_features([tms.feature(x, projected=False) for x in tms.tiles(*coords, zooms=[ZOOM_LEVEL])])
61 | tiles.set_crs(epsg=4326, inplace=True)
62 | tiles_all.append(tiles)
63 | tiles_all_gdf = gpd.GeoDataFrame(pd.concat(tiles_all, ignore_index=True))
64 |
65 | return tiles_all_gdf
66 |
67 |
68 | def assert_year(gdf1, gdf2, ds, year):
69 | """Assert if the year of the dataset is well supported
70 |
71 | Args:
72 | gdf1 (GeoDataFrame): label geodataframe
73 | gdf2 (GeoDataFrame): other geodataframe to compare columns
74 | ds (string): dataset type (FP, empty tiles,...)
75 | year (string or numeric): attribution of year to tiles
76 | """
77 |
78 | if ('year' not in gdf1.keys() and 'year' not in gdf2.keys()) or ('year' not in gdf1.keys() and year == None):
79 | pass
80 | elif ds == 'FP':
81 | if ('year' in gdf1.keys() and 'year' in gdf2.keys()):
82 | pass
83 | else:
84 | logger.error("One input label (GT or FP) shapefile contains a 'year' column while the other one no. Please, standardize the label shapefiles supplied as input data.")
85 | sys.exit(1)
86 | elif ds == 'empty_tiles':
87 | if ('year' in gdf1.keys() and 'year' in gdf2.keys()) or ('year' in gdf1.keys() and year != None):
88 | pass
89 | elif 'year' in gdf1.keys() and 'year' not in gdf2.keys():
90 | logger.error("A 'year' column is provided in the GT shapefile but not for the empty tiles. Please, standardize the label shapefiles supplied as input data.")
91 | sys.exit(1)
92 | elif 'year' in gdf1.keys() and year == None:
93 | logger.error("A 'year' column is provided in the GT shapefile but no year info for the empty tiles. Please, provide a value to 'empty_tiles_year' in the configuration file.")
94 | sys.exit(1)
95 | elif ('year' not in gdf1.keys() and 'year' not in gdf2.keys()) and ('year' not in gdf1.keys() and year != None):
96 | logger.error("A year is provided for the empty tiles while no 'year' column is provided in the groud truth shapefile. Please, standardize the shapefiles or the year value in the configuration file.")
97 | sys.exit(1)
98 |
99 |
100 | def bbox(bounds):
101 | """Get a vector bounding box of a 2D shape
102 |
103 | Args:
104 | bounds (array): minx, miny, maxx, maxy of the bounding box
105 |
106 | Returns:
107 | geometry (Polygon): polygon geometry of the bounding box
108 | """
109 |
110 | minx = bounds[0]
111 | miny = bounds[1]
112 | maxx = bounds[2]
113 | maxy = bounds[3]
114 |
115 | return Polygon([[minx, miny],
116 | [maxx, miny],
117 | [maxx, maxy],
118 | [minx, maxy]])
119 |
120 |
121 | if __name__ == "__main__":
122 |
123 | # Start chronometer
124 | tic = time.time()
125 | logger.info('Starting...')
126 |
127 | # Argument and parameter specification
128 | parser = argparse.ArgumentParser(description="The script prepares the ground truth dataset to be processed by the object-detector scripts")
129 | parser.add_argument('config_file', type=str, help='Framework configuration file')
130 | args = parser.parse_args()
131 |
132 | logger.info(f"Using {args.config_file} as config file.")
133 |
134 | with open(args.config_file) as fp:
135 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
136 |
137 | # Load input parameters
138 | OUTPUT_DIR = cfg['output_folder']
139 | SHPFILE = cfg['datasets']['shapefile']
140 | CATEGORY = cfg['datasets']['category'] if 'category' in cfg['datasets'].keys() else False
141 | FP_SHPFILE = cfg['datasets']['fp_shapefile'] if 'fp_shapefile' in cfg['datasets'].keys() else None
142 | EPT_YEAR = cfg['datasets']['empty_tiles_year'] if 'empty_tiles_year' in cfg['datasets'].keys() else None
143 | if 'empty_tiles_aoi' in cfg['datasets'].keys() and 'empty_tiles_shp' in cfg['datasets'].keys():
144 | logger.error("Choose between supplying an AoI shapefile ('empty_tiles_aoi') in which empty tiles will be selected, or a shapefile with selected empty tiles ('empty_tiles_shp')")
145 | sys.exit(1)
146 | elif 'empty_tiles_aoi' in cfg['datasets'].keys():
147 | EPT_SHPFILE = cfg['datasets']['empty_tiles_aoi']
148 | EPT = 'aoi'
149 | elif 'empty_tiles_shp' in cfg['datasets'].keys():
150 | EPT_SHPFILE = cfg['datasets']['empty_tiles_shp']
151 | EPT = 'shp'
152 | else:
153 | EPT_SHPFILE = None
154 | EPT = None
155 | CATEGORY = cfg['datasets']['category'] if 'category' in cfg['datasets'].keys() else False
156 | ZOOM_LEVEL = cfg['zoom_level']
157 |
158 | # Create an output directory in case it doesn't exist
159 | if not os.path.exists(OUTPUT_DIR):
160 | os.makedirs(OUTPUT_DIR)
161 |
162 | written_files = []
163 |
164 | # Prepare the tiles
165 |
166 | ## Convert datasets shapefiles into geojson format
167 | logger.info('Convert labels shapefile into GeoJSON format (EPSG:4326)...')
168 | labels_gdf = gpd.read_file(SHPFILE)
169 | if 'year' in labels_gdf.keys():
170 | labels_gdf['year'] = labels_gdf.year.astype(int)
171 | labels_4326_gdf = labels_gdf.to_crs(epsg=4326).drop_duplicates(subset=['geometry', 'year'])
172 | else:
173 | labels_4326_gdf = labels_gdf.to_crs(epsg=4326).drop_duplicates(subset=['geometry'])
174 | nb_labels = len(labels_gdf)
175 | logger.info(f'There are {nb_labels} polygons in {SHPFILE}')
176 |
177 | labels_4326_gdf['CATEGORY'] = 'quarry'
178 | labels_4326_gdf['SUPERCATEGORY'] = 'land usage'
179 |
180 | gt_labels_4326_gdf = labels_4326_gdf.copy()
181 |
182 | label_filename = 'labels.geojson'
183 | label_filepath = os.path.join(OUTPUT_DIR, label_filename)
184 | labels_4326_gdf.to_file(label_filepath, driver='GeoJSON')
185 | written_files.append(label_filepath)
186 | logger.success(f"{DONE_MSG} A file was written: {label_filepath}")
187 |
188 | # Add FP labels if it exists
189 | if FP_SHPFILE:
190 | fp_labels_gdf = gpd.read_file(FP_SHPFILE)
191 | assert_year(fp_labels_gdf, labels_gdf, 'FP', EPT_YEAR)
192 | if 'year' in fp_labels_gdf.keys():
193 | fp_labels_gdf['year'] = fp_labels_gdf.year.astype(int)
194 | fp_labels_4326_gdf = fp_labels_gdf.to_crs(epsg=4326).drop_duplicates(subset=['geometry', 'year'])
195 | else:
196 | fp_labels_4326_gdf = fp_labels_gdf.to_crs(epsg=4326).drop_duplicates(subset=['geometry'])
197 | fp_labels_4326_gdf['CATEGORY'] = 'quarry'
198 | fp_labels_4326_gdf['SUPERCATEGORY'] = 'land usage'
199 |
200 | nb_fp_labels = len(fp_labels_gdf)
201 | logger.info(f"There are {nb_fp_labels} polygons in {FP_SHPFILE}")
202 |
203 | filename = 'FP.geojson'
204 | filepath = os.path.join(OUTPUT_DIR, filename)
205 | fp_labels_4326_gdf.to_file(filepath, driver='GeoJSON')
206 | written_files.append(filepath)
207 | logger.success(f"{DONE_MSG} A file was written: {filepath}")
208 | labels_4326_gdf = pd.concat([labels_4326_gdf, fp_labels_4326_gdf], ignore_index=True)
209 |
210 | # Tiling of the AoI
211 | logger.info("- Get the label boundaries")
212 | boundaries_df = labels_4326_gdf.bounds
213 | logger.info("- Tiling of the AoI")
214 | tiles_4326_aoi_gdf = aoi_tiling(boundaries_df)
215 | tiles_4326_labels_gdf = gpd.sjoin(tiles_4326_aoi_gdf, labels_4326_gdf, how='inner', predicate='intersects')
216 |
217 | # Tiling of the AoI from which empty tiles will be selected
218 | if EPT_SHPFILE:
219 | EPT_aoi_gdf = gpd.read_file(EPT_SHPFILE)
220 | EPT_aoi_4326_gdf = EPT_aoi_gdf.to_crs(epsg=4326)
221 | assert_year(labels_4326_gdf, EPT_aoi_4326_gdf, 'empty_tiles', EPT_YEAR)
222 |
223 | if EPT == 'aoi':
224 | logger.info("- Get AoI boundaries")
225 | EPT_aoi_boundaries_df = EPT_aoi_4326_gdf.bounds
226 |
227 | # Get tile coordinates and shapes
228 | logger.info("- Tiling of the empty tiles AoI")
229 | empty_tiles_4326_all_gdf = aoi_tiling(EPT_aoi_boundaries_df)
230 | # Delete tiles outside of the AoI limits
231 | empty_tiles_4326_aoi_gdf = gpd.sjoin(empty_tiles_4326_all_gdf, EPT_aoi_4326_gdf, how='inner', lsuffix='ept_tiles', rsuffix='ept_aoi')
232 | # Attribute a year to empty tiles if necessary
233 | if 'year' in labels_4326_gdf.keys():
234 | if isinstance(EPT_YEAR, int):
235 | empty_tiles_4326_aoi_gdf['year'] = int(EPT_YEAR)
236 | else:
237 | empty_tiles_4326_aoi_gdf['year'] = np.random.randint(low=EPT_YEAR[0], high=EPT_YEAR[1], size=(len(empty_tiles_4326_aoi_gdf)))
238 | elif EPT_SHPFILE and EPT_YEAR:
239 | logger.warning("No year column in the label shapefile. The provided empty tile year will be ignored.")
240 | elif EPT == 'shp':
241 | if EPT_YEAR:
242 | logger.warning("A shapefile of selected empty tiles are provided. The year set for the empty tiles in the configuration file will be ignored")
243 | EPT_YEAR = None
244 | empty_tiles_4326_aoi_gdf = EPT_aoi_4326_gdf.copy()
245 |
246 | # Get all the tiles in one gdf
247 | if EPT_SHPFILE:
248 | logger.info("- Concatenate label tiles and empty AoI tiles")
249 | tiles_4326_all_gdf = pd.concat([tiles_4326_labels_gdf, empty_tiles_4326_aoi_gdf])
250 | else:
251 | tiles_4326_all_gdf = tiles_4326_labels_gdf.copy()
252 |
253 | # - Remove useless columns, reset feature id and redefine it according to xyz format
254 | logger.info('- Add tile IDs and reorganise the data set')
255 | tiles_4326_all_gdf = tiles_4326_all_gdf[['geometry', 'title', 'year'] if 'year' in tiles_4326_all_gdf.keys() else ['geometry', 'title']].copy()
256 | tiles_4326_all_gdf.reset_index(drop=True, inplace=True)
257 | tiles_4326_all_gdf = tiles_4326_all_gdf.apply(add_tile_id, axis=1)
258 |
259 | # - Remove duplicated tiles
260 | if nb_labels > 1:
261 | tiles_4326_all_gdf.drop_duplicates(['id'], inplace=True)
262 |
263 | nb_tiles = len(tiles_4326_all_gdf)
264 | logger.info(f"There were {nb_tiles} tiles created")
265 |
266 | # Get the number of tiles intersecting labels
267 | tiles_4326_gt_gdf = gpd.sjoin(tiles_4326_all_gdf, gt_labels_4326_gdf[['geometry', 'CATEGORY', 'SUPERCATEGORY']], how='inner', predicate='intersects')
268 | tiles_4326_gt_gdf.drop_duplicates(['id'], inplace=True)
269 | logger.info(f"- Number of tiles intersecting GT labels = {len(tiles_4326_gt_gdf)}")
270 |
271 | if FP_SHPFILE:
272 | tiles_4326_fp_gdf = gpd.sjoin(tiles_4326_all_gdf, fp_labels_4326_gdf, how='inner', predicate='intersects')
273 | tiles_4326_fp_gdf.drop_duplicates(['id'], inplace=True)
274 | logger.info(f"- Number of tiles intersecting FP labels = {len(tiles_4326_fp_gdf)}")
275 |
276 | # Save tile shapefile
277 | logger.info("Export tiles to GeoJSON (EPSG:4326)...")
278 | tile_filename = 'tiles.geojson'
279 | tile_filepath = os.path.join(OUTPUT_DIR, tile_filename)
280 | tiles_4326_all_gdf.to_file(tile_filepath, driver='GeoJSON')
281 | written_files.append(tile_filepath)
282 | logger.success(f"{DONE_MSG} A file was written: {tile_filepath}")
283 |
284 | print()
285 | logger.info("The following files were written. Let's check them out!")
286 | for written_file in written_files:
287 | logger.info(written_file)
288 | print()
289 |
290 | # Stop chronometer
291 | toc = time.time()
292 | logger.info(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
293 |
294 | sys.stderr.flush()
--------------------------------------------------------------------------------
/examples/road-surface-classification/README.md:
--------------------------------------------------------------------------------
1 | # Example: determination of the type or road surface
2 |
3 | A sample working setup is provided here to allow, allowing the end-user to detect roads and determine their type of surface (natural or artificial). This example is illustrating the functioning for the multi-class case.
4 | It is made of the following assets:
5 |
6 | - the read-to-use configuration files
7 | - `config_rs.yaml`
8 | - `detectron2_config_3bands.yaml`,
9 | - the initial data in the `data` subfolder:
10 | - the roads and forests from the product swissTLM3D
11 | - the delimitation of the AOI
12 | - an excel file with the road parameters,
13 | - a data preparation script (`prepare_data.py`) producing the files to be used as input to the `generate_tilesets.py`script.
14 |
15 | Installation can be carried out by following the instructions in the main readme file. When using docker, the container must be launched before running the workflow:
16 |
17 | ```bash
18 | $ sudo chown -R 65534:65534 examples
19 | $ docker compose run --rm -it stdl-objdet
20 | ```
21 |
22 | The end-to-end workflow can be run by issuing the following list of commands:
23 |
24 | ```bash
25 | $ cd examples/road-surface-classification
26 | $ python prepare_data.py config_rs.yaml
27 | $ stdl-objdet generate_tilesets config_rs.yaml
28 | $ stdl-objdet train_model config_rs.yaml
29 | $ stdl-objdet make_detections config_rs.yaml
30 | $ stdl-objdet assess_detections config_rs.yaml
31 | ```
32 |
33 | The docker container is exited and permissions restored with:
34 |
35 | ```bash
36 | $ exit
37 | $ sudo chmod -R a+w examples
38 | ```
39 |
40 | This example is made up from a subset of the data used in the proj-roadsurf project. For more information about this project, you can consult [the associated repository](https://github.com/swiss-territorial-data-lab/proj-roadsurf) and [its full documentation](https://tech.stdl.ch/PROJ-ROADSURF/).
41 | The original project does not use the original script for assessment but has his own.
42 |
43 | We strongly encourage the end-user to review the provided `config_rs.yaml` file as well as the various output files, a list of which is printed by each script before exiting.
--------------------------------------------------------------------------------
/examples/road-surface-classification/config_rs.yaml:
--------------------------------------------------------------------------------
1 | prepare_data.py:
2 | working_directory: .
3 | tasks:
4 | determine_roads_surfaces: true
5 | generate_tiles_info: true
6 | generate_labels: true
7 | input:
8 | input_folder: data
9 | input_files:
10 | roads: swissTLM3D/roads_lines.shp
11 | roads_param: roads_parameters.xlsx
12 | forests: swissTLM3D/forests.shp
13 | aoi: AOI/AOI.shp
14 | restricted_aoi_training: AOI/training_AOI.shp
15 | processed_input:
16 | roads_for_labels: roads_for_OD.shp
17 | output_folder: outputs_RS
18 | zoom_level: 18 # keep between 17 and 20
19 |
20 | generate_tilesets.py:
21 | debug_mode:
22 | enable: False # sample of tiles
23 | nb_tiles_max: 500
24 | working_directory: outputs_RS
25 | output_folder: .
26 | datasets:
27 | aoi_tiles: json_inputs/tiles_aoi.geojson
28 | ground_truth_labels: json_inputs/ground_truth_labels.geojson
29 | other_labels: json_inputs/other_labels.geojson
30 | image_source:
31 | type: XYZ
32 | year: 2018
33 | location: https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/{year}/3857/{z}/{x}/{y}.jpeg
34 | srs: "EPSG:3857"
35 | tile_size: 256 # per side, in pixels
36 | overwrite: False
37 | n_jobs: 10
38 | seed: 2
39 | COCO_metadata:
40 | year: 2022
41 | version: 2.0
42 | description: 2018 SWISSIMAGE RS with segmentation of Feature Class TLM_STRASSE
43 | contributor: swisstopo
44 | url: https://swisstopo.ch
45 | license:
46 | name: unknown
47 | url: https://www.swisstopo.admin.ch/fr/home/meta/conditions-generales/geodonnees/ogd.html
48 |
49 | train_model.py:
50 | debug_mode: False
51 | working_directory: outputs_RS
52 | log_subfolder: logs
53 | sample_tagged_img_subfolder: sample_training_images
54 | COCO_files: # relative paths, w/ respect to the working_folder
55 | trn: COCO_trn.json
56 | val: COCO_val.json
57 | tst: COCO_tst.json
58 | detectron2_config_file: ../detectron2_config_3bands.yaml # path relative to the working_folder
59 | model_weights:
60 | model_zoo_checkpoint_url: COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml
61 |
62 | make_detections.py:
63 | working_directory: outputs_RS
64 | log_subfolder: logs
65 | sample_tagged_img_subfolder: sample_detection_images
66 | COCO_files: # relative paths, w/ respect to the working_folder
67 | trn: COCO_trn.json
68 | val: COCO_val.json
69 | tst: COCO_tst.json
70 | oth: COCO_oth.json
71 | detectron2_config_file: ../detectron2_config_3bands.yaml # path relative to the working_folder
72 | model_weights:
73 | pth_file: logs/model_0011499.pth
74 | image_metadata_json: img_metadata.json
75 | rdp_simplification: # rdp = Ramer-Douglas-Peucker
76 | enabled: true
77 | epsilon: 0.75 # cf. https://rdp.readthedocs.io/en/latest/
78 | score_lower_threshold: 0.05
79 | remove_det_overlap: False # if several detections overlap (IoU > 0.5), only the one with the highest confidence score is retained
80 |
81 | assess_detections.py:
82 | working_directory: outputs_RS
83 | datasets:
84 | ground_truth_labels: json_inputs/ground_truth_labels.geojson
85 | other_labels: json_inputs/other_labels.geojson
86 | split_aoi_tiles: split_aoi_tiles.geojson
87 | categories: category_ids.json
88 | detections:
89 | trn: trn_detections_at_0dot05_threshold.gpkg
90 | val: val_detections_at_0dot05_threshold.gpkg
91 | tst: tst_detections_at_0dot05_threshold.gpkg
92 | oth: oth_detections_at_0dot05_threshold.gpkg
93 | output_folder: .
94 | iou_threshold: 0.1
95 | metrics_method: macro-average # 1: macro-average ; 3: macro-weighted-average ; 2: micro-average
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/AOI.dbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/AOI.dbf
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/AOI.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/AOI.qmd:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | dataset
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 0
26 | 0
27 |
28 |
29 |
30 |
31 | false
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/AOI.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/AOI.shp
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/AOI.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/AOI.shx
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/training_AOI.dbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/training_AOI.dbf
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/training_AOI.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/training_AOI.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/training_AOI.shp
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/AOI/training_AOI.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/AOI/training_AOI.shx
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/roads_parameters.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/roads_parameters.xlsx
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.cpg:
--------------------------------------------------------------------------------
1 | UTF-8
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.qix:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/forests.qix
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.qmd:
--------------------------------------------------------------------------------
1 |
2 |
3 | Clip_Strassen2
4 |
5 | FRA
6 | dataset
7 | Clip_Strassen2
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | PROJCRS["CH1903+ / LV95",BASEGEOGCRS["CH1903+",DATUM["CH1903+",ELLIPSOID["Bessel 1841",6377397.155,299.1528128,LENGTHUNIT["metre",1]]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],ID["EPSG",4150]],CONVERSION["Swiss Oblique Mercator 1995",METHOD["Hotine Oblique Mercator (variant B)",ID["EPSG",9815]],PARAMETER["Latitude of projection centre",46.9524055555556,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8811]],PARAMETER["Longitude of projection centre",7.43958333333333,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8812]],PARAMETER["Azimuth of initial line",90,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8813]],PARAMETER["Angle from Rectified to Skew Grid",90,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8814]],PARAMETER["Scale factor on initial line",1,SCALEUNIT["unity",1],ID["EPSG",8815]],PARAMETER["Easting at projection centre",2600000,LENGTHUNIT["metre",1],ID["EPSG",8816]],PARAMETER["Northing at projection centre",1200000,LENGTHUNIT["metre",1],ID["EPSG",8817]]],CS[Cartesian,2],AXIS["(E)",east,ORDER[1],LENGTHUNIT["metre",1]],AXIS["(N)",north,ORDER[2],LENGTHUNIT["metre",1]],USAGE[SCOPE["unknown"],AREA["Europe - Liechtenstein and Switzerland"],BBOX[45.82,5.96,47.81,10.49]],ID["EPSG",2056]]
24 | +proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs
25 | 47
26 | 2056
27 | EPSG:2056
28 | CH1903+ / LV95
29 | somerc
30 | EPSG:7004
31 | false
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/forests.shp
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/forests.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/forests.shx
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.cpg:
--------------------------------------------------------------------------------
1 | UTF-8
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.dbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/roads_lines.dbf
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.qix:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/roads_lines.qix
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.qmd:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | dataset
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 0
26 | 0
27 |
28 |
29 |
30 |
31 | false
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/roads_lines.shp
--------------------------------------------------------------------------------
/examples/road-surface-classification/data/swissTLM3D/roads_lines.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/road-surface-classification/data/swissTLM3D/roads_lines.shx
--------------------------------------------------------------------------------
/examples/road-surface-classification/detectron2_config_3bands.yaml:
--------------------------------------------------------------------------------
1 | CUDNN_BENCHMARK: false
2 | DATALOADER:
3 | ASPECT_RATIO_GROUPING: true
4 | FILTER_EMPTY_ANNOTATIONS: true
5 | NUM_WORKERS: 4
6 | REPEAT_THRESHOLD: 0.0
7 | SAMPLER_TRAIN: TrainingSampler
8 | DATASETS:
9 | PRECOMPUTED_PROPOSAL_TOPK_TEST: 1000
10 | PRECOMPUTED_PROPOSAL_TOPK_TRAIN: 2000
11 | PROPOSAL_FILES_TEST: []
12 | PROPOSAL_FILES_TRAIN: []
13 | TEST:
14 | - val_dataset
15 | TRAIN:
16 | - trn_dataset
17 | GLOBAL:
18 | HACK: 1.0
19 | INPUT:
20 | CROP:
21 | ENABLED: false
22 | SIZE:
23 | - 0.9
24 | - 0.9
25 | TYPE: relative_range
26 | FORMAT: RGB
27 | MASK_FORMAT: polygon
28 | MAX_SIZE_TEST: 1333
29 | MAX_SIZE_TRAIN: 1333
30 | MIN_SIZE_TEST: 800
31 | MIN_SIZE_TRAIN:
32 | - 640
33 | - 672
34 | - 704
35 | - 736
36 | - 768
37 | - 800
38 | MIN_SIZE_TRAIN_SAMPLING: choice
39 | MODEL:
40 | ANCHOR_GENERATOR:
41 | ANGLES:
42 | - - -90
43 | - 0
44 | - 90
45 | ASPECT_RATIOS:
46 | - - 0.5
47 | - 1.0
48 | - 2.0
49 | NAME: DefaultAnchorGenerator
50 | OFFSET: 0.0
51 | SIZES:
52 | - - 32
53 | - - 64
54 | - - 128
55 | - - 256
56 | - - 512
57 | BACKBONE:
58 | FREEZE_AT: 2
59 | NAME: build_resnet_fpn_backbone
60 | DEVICE: cuda
61 | FPN:
62 | FUSE_TYPE: sum
63 | IN_FEATURES:
64 | - res2
65 | - res3
66 | - res4
67 | - res5
68 | NORM: ''
69 | OUT_CHANNELS: 256
70 | KEYPOINT_ON: false
71 | LOAD_PROPOSALS: false
72 | MASK_ON: true
73 | META_ARCHITECTURE: GeneralizedRCNN
74 | PANOPTIC_FPN:
75 | COMBINE:
76 | ENABLED: true
77 | INSTANCES_CONFIDENCE_THRESH: 0.5
78 | OVERLAP_THRESH: 0.5
79 | STUFF_AREA_LIMIT: 4096
80 | INSTANCE_LOSS_WEIGHT: 1.0
81 | PIXEL_MEAN: # RGB order
82 | - 123.675
83 | - 116.28
84 | - 103.53
85 | PIXEL_STD:
86 | - 1.0
87 | - 1.0
88 | - 1.0
89 | PROPOSAL_GENERATOR:
90 | MIN_SIZE: 0
91 | NAME: RPN
92 | RESNETS:
93 | DEFORM_MODULATED: false
94 | DEFORM_NUM_GROUPS: 1
95 | DEFORM_ON_PER_STAGE:
96 | - false
97 | - false
98 | - false
99 | - false
100 | DEPTH: 50
101 | NORM: FrozenBN
102 | NUM_GROUPS: 1
103 | OUT_FEATURES:
104 | - res2
105 | - res3
106 | - res4
107 | - res5
108 | RES2_OUT_CHANNELS: 256
109 | RES5_DILATION: 1
110 | STEM_OUT_CHANNELS: 64
111 | STRIDE_IN_1X1: true
112 | WIDTH_PER_GROUP: 64
113 | RETINANET:
114 | BBOX_REG_WEIGHTS:
115 | - 1.0
116 | - 1.0
117 | - 1.0
118 | - 1.0
119 | FOCAL_LOSS_ALPHA: 0.25
120 | FOCAL_LOSS_GAMMA: 2.0
121 | IN_FEATURES:
122 | - p3
123 | - p4
124 | - p5
125 | - p6
126 | - p7
127 | IOU_LABELS:
128 | - 0
129 | - -1
130 | - 1
131 | IOU_THRESHOLDS:
132 | - 0.4
133 | - 0.5
134 | NMS_THRESH_TEST: 0.5
135 | NUM_CONVS: 4
136 | PRIOR_PROB: 0.01
137 | SCORE_THRESH_TEST: 0.05
138 | SMOOTH_L1_LOSS_BETA: 0.1
139 | TOPK_CANDIDATES_TEST: 1000
140 | ROI_BOX_CASCADE_HEAD:
141 | BBOX_REG_WEIGHTS:
142 | - - 10.0
143 | - 10.0
144 | - 5.0
145 | - 5.0
146 | - - 20.0
147 | - 20.0
148 | - 10.0
149 | - 10.0
150 | - - 30.0
151 | - 30.0
152 | - 15.0
153 | - 15.0
154 | IOUS:
155 | - 0.5
156 | - 0.6
157 | - 0.7
158 | ROI_BOX_HEAD:
159 | BBOX_REG_WEIGHTS:
160 | - 10.0
161 | - 10.0
162 | - 5.0
163 | - 5.0
164 | CLS_AGNOSTIC_BBOX_REG: false
165 | CONV_DIM: 256
166 | FC_DIM: 1024
167 | NAME: FastRCNNConvFCHead
168 | NORM: ''
169 | NUM_CONV: 0
170 | NUM_FC: 2
171 | POOLER_RESOLUTION: 7
172 | POOLER_SAMPLING_RATIO: 0
173 | POOLER_TYPE: ROIAlignV2
174 | SMOOTH_L1_BETA: 0.0
175 | TRAIN_ON_PRED_BOXES: false
176 | ROI_HEADS:
177 | BATCH_SIZE_PER_IMAGE: 1024
178 | IN_FEATURES:
179 | - p2
180 | - p3
181 | - p4
182 | - p5
183 | IOU_LABELS:
184 | - 0
185 | - 1
186 | IOU_THRESHOLDS:
187 | - 0.5
188 | NAME: StandardROIHeads
189 | NMS_THRESH_TEST: 0.5
190 | NUM_CLASSES: 2
191 | POSITIVE_FRACTION: 0.25
192 | PROPOSAL_APPEND_GT: true
193 | SCORE_THRESH_TEST: 0.05
194 | ROI_KEYPOINT_HEAD:
195 | CONV_DIMS:
196 | - 512
197 | - 512
198 | - 512
199 | - 512
200 | - 512
201 | - 512
202 | - 512
203 | - 512
204 | LOSS_WEIGHT: 1.0
205 | MIN_KEYPOINTS_PER_IMAGE: 1
206 | NAME: KRCNNConvDeconvUpsampleHead
207 | NORMALIZE_LOSS_BY_VISIBLE_KEYPOINTS: true
208 | NUM_KEYPOINTS: 17
209 | POOLER_RESOLUTION: 14
210 | POOLER_SAMPLING_RATIO: 0
211 | POOLER_TYPE: ROIAlignV2
212 | ROI_MASK_HEAD:
213 | CLS_AGNOSTIC_MASK: false
214 | CONV_DIM: 256
215 | NAME: MaskRCNNConvUpsampleHead
216 | NORM: ''
217 | NUM_CONV: 4
218 | POOLER_RESOLUTION: 14
219 | POOLER_SAMPLING_RATIO: 0
220 | POOLER_TYPE: ROIAlignV2
221 | RPN: # Region proposal network
222 | BATCH_SIZE_PER_IMAGE: 256
223 | BBOX_REG_WEIGHTS:
224 | - 1.0
225 | - 1.0
226 | - 1.0
227 | - 1.0
228 | BOUNDARY_THRESH: -1
229 | HEAD_NAME: StandardRPNHead
230 | IN_FEATURES:
231 | - p2
232 | - p3
233 | - p4
234 | - p5
235 | - p6
236 | IOU_LABELS:
237 | - 0
238 | - -1
239 | - 1
240 | IOU_THRESHOLDS:
241 | - 0.3
242 | - 0.7
243 | LOSS_WEIGHT: 1.0
244 | NMS_THRESH: 0.7
245 | POSITIVE_FRACTION: 0.5
246 | POST_NMS_TOPK_TEST: 1000
247 | POST_NMS_TOPK_TRAIN: 1000
248 | PRE_NMS_TOPK_TEST: 1000
249 | PRE_NMS_TOPK_TRAIN: 2000
250 | SMOOTH_L1_BETA: 0.0
251 | SEM_SEG_HEAD:
252 | COMMON_STRIDE: 4
253 | CONVS_DIM: 128
254 | IGNORE_VALUE: 255
255 | IN_FEATURES:
256 | - p2
257 | - p3
258 | - p4
259 | - p5
260 | LOSS_WEIGHT: 1.0
261 | NAME: SemSegFPNHead
262 | NORM: GN
263 | NUM_CLASSES: 54
264 | WEIGHTS: https://dl.fbaipublicfiles.com/detectron2/COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x/137260431/model_final_a54504.pkl
265 | OUTPUT_DIR: logs
266 | SEED: -1
267 | SOLVER:
268 | BASE_LR: 0.02
269 | BIAS_LR_FACTOR: 1.0
270 | CHECKPOINT_PERIOD: 500
271 | CLIP_GRADIENTS:
272 | CLIP_TYPE: value
273 | CLIP_VALUE: 1.0
274 | ENABLED: false
275 | NORM_TYPE: 2.0
276 | GAMMA: 0.8
277 | IMS_PER_BATCH: 12
278 | LR_SCHEDULER_NAME: WarmupMultiStepLR
279 | MAX_ITER: 15000
280 | MOMENTUM: 0.9
281 | NESTEROV: false
282 | STEPS:
283 | - 6000
284 | - 7000
285 | - 8000
286 | - 9000
287 | - 10000
288 | - 11000
289 | - 12000
290 | - 13000
291 | - 14000
292 | WARMUP_FACTOR: 0.001
293 | WARMUP_ITERS: 200
294 | WARMUP_METHOD: linear
295 | WEIGHT_DECAY: 0.0001
296 | WEIGHT_DECAY_BIAS: None
297 | WEIGHT_DECAY_NORM: 0.0
298 | TEST:
299 | AUG:
300 | ENABLED: false
301 | FLIP: true
302 | MAX_SIZE: 4000
303 | MIN_SIZES:
304 | - 400
305 | - 500
306 | - 600
307 | - 700
308 | - 800
309 | - 900
310 | - 1000
311 | - 1100
312 | - 1200
313 | DETECTIONS_PER_IMAGE: 100
314 | EVAL_PERIOD: 200
315 | EXPECTED_RESULTS: []
316 | KEYPOINT_OKS_SIGMAS: []
317 | PRECISE_BN:
318 | ENABLED: false
319 | NUM_ITER: 200
320 | VERSION: 2
321 | VIS_PERIOD: 0
322 |
323 |
--------------------------------------------------------------------------------
/examples/road-surface-classification/fct_misc.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os
3 |
4 | import geopandas as gpd
5 | import pandas as pd
6 |
7 | import numpy as np
8 |
9 | def test_crs(crs1, crs2 = "EPSG:2056"):
10 | '''
11 | Take the crs of two dataframes and compare them. If they are not the same, stop the script.
12 | '''
13 | if isinstance(crs1, gpd.GeoDataFrame):
14 | crs1=crs1.crs
15 | if isinstance(crs2, gpd.GeoDataFrame):
16 | crs2=crs2.crs
17 |
18 | try:
19 | assert(crs1 == crs2), f"CRS mismatch between the two files ({crs1} vs {crs2})."
20 | except Exception as e:
21 | print(e)
22 | sys.exit(1)
23 |
24 | def ensure_dir_exists(dirpath):
25 | '''
26 | Test if a directory exists. If not, make it.
27 |
28 | return: the path to the verified directory.
29 | '''
30 |
31 | if not os.path.exists(dirpath):
32 | os.makedirs(dirpath)
33 | print(f"The directory {dirpath} was created.")
34 |
35 | return dirpath
36 |
37 |
38 |
39 | def polygons_diff_without_artifacts(polygons, p1_idx, p2_idx, keep_everything=False):
40 | '''
41 | Make the difference of the geometry at row p2_idx with the one at the row p1_idx
42 |
43 | - polygons: dataframe of polygons
44 | - p1_idx: index of the "obstacle" polygon in the dataset
45 | - p2_idx: index of the final polygon
46 | - keep_everything: boolean indicating if we should keep large parts that would be eliminated otherwise
47 |
48 | return: a dataframe of the polygons where the part of p1_idx overlapping with p2_idx has been erased. The parts of
49 | multipolygons can be all kept or just the largest one (longer process).
50 | '''
51 |
52 | # Store intermediary results back to poly
53 | diff=polygons.loc[p2_idx,'geometry']-polygons.loc[p1_idx,'geometry']
54 |
55 | if diff.geom_type == 'Polygon':
56 | polygons.loc[p2_idx,'geometry'] -= polygons.loc[p1_idx,'geometry']
57 |
58 | elif diff.geom_type == 'MultiPolygon':
59 | # if a multipolygone is created, only keep the largest part to avoid the following error: https://github.com/geopandas/geopandas/issues/992
60 | polygons.loc[p2_idx,'geometry'] = max((polygons.loc[p2_idx,'geometry']-polygons.loc[p1_idx,'geometry']).geoms, key=lambda a: a.area)
61 |
62 | # The threshold to which we consider that subparts are still important is hard-coded at 10 units.
63 | limit=10
64 | parts_geom=[poly for poly in diff.geoms if poly.area>limit]
65 | if len(parts_geom)>1 and keep_everything:
66 | parts_area=[poly.area for poly in diff.geoms if poly.area>limit]
67 | parts=pd.DataFrame({'geometry':parts_geom,'area':parts_area})
68 | parts.sort_values(by='area', ascending=False, inplace=True)
69 |
70 | new_row_serie=polygons.loc[p2_idx].copy()
71 | new_row_dict={'OBJECTID': [], 'OBJEKTART': [], 'KUNSTBAUTE': [], 'BELAGSART': [], 'geometry': [],
72 | 'GDB-Code': [], 'Width': [], 'saved_geom': []}
73 | new_poly=0
74 | for elem_geom in parts['geometry'].values[1:]:
75 |
76 | new_row_dict['OBJECTID'].append(int(str(int(new_row_serie.OBJECTID))+str(new_poly)))
77 | new_row_dict['geometry'].append(elem_geom)
78 | new_row_dict['OBJEKTART'].append(new_row_serie.OBJEKTART)
79 | new_row_dict['KUNSTBAUTE'].append(new_row_serie.KUNSTBAUTE)
80 | new_row_dict['BELAGSART'].append(new_row_serie.BELAGSART)
81 | new_row_dict['GDB-Code'].append(new_row_serie['GDB-Code'])
82 | new_row_dict['Width'].append(new_row_serie.Width)
83 | new_row_dict['saved_geom'].append(new_row_serie.saved_geom)
84 |
85 | new_poly+=1
86 |
87 | polygons=pd.concat([polygons, pd.DataFrame(new_row_dict)], ignore_index=True)
88 |
89 | return polygons
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/GE/README.md:
--------------------------------------------------------------------------------
1 | # Example: detecting swimming pools over the Canton of Geneva
2 |
3 | A sample working setup is here provided, allowing the end-user to detect swimming pools over the Canton of Geneva. It is made up by the following assets:
4 |
5 | * ready-to-use configuration files, namely `config_GE.yaml` and `detectron2_config_GE.yaml`.
6 | * Supplementary data (`data/OK_z18_tile_IDs.csv`), *i.e.* a curated list of Slippy Map Tiles corresponding to zoom level 18, which seemed to include reliable "ground-truth data" when they were manually checked against the [SITG's "Piscines" Open Dataset](https://ge.ch/sitg/fiche/1836), in Summer 2020. The thoughtful user should either review or regenerate this file in order to get better results.
7 | * A data preparation script (`prepare_data.py`), producing files to be used as input to the `generate_tilesets` stage.
8 |
9 | The workflow can be run end-to-end by issuing the following list of commands, from the root folder of this GitHub repository:
10 |
11 | ```
12 | $ sudo chown -R 65534:65534 examples
13 | $ docker compose run --rm -it stdl-objdet
14 | nobody@:/app# cd examples/swimming-pool-detection/GE
15 | nobody@:/app# python prepare_data.py config_GE.yaml
16 | nobody@:/app# cd output_GE && cat parcels.geojson | supermercado burn 18 | mercantile shapes | fio collect > parcels_z18_tiles.geojson && cd -
17 | nobody@:/app# python prepare_data.py config_GE.yaml
18 | nobody@:/app# stdl-objdet generate_tilesets config_GE.yaml
19 | nobody@:/app# stdl-objdet train_model config_GE.yaml
20 | nobody@:/app# stdl-objdet make_detections config_GE.yaml
21 | nobody@:/app# stdl-objdet assess_detections config_GE.yaml
22 | nobody@:/app# exit
23 | $ sudo chmod -R a+w examples
24 | ```
25 |
26 | We strongly encourage the end-user to review the provided `config_GE.yaml` file as well as the various output files, a list of which is printed by each script before exiting.
27 |
28 | Due to timeout of the WMS service, the user might have to run the tileset generation several times.
29 |
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/GE/config_GE.yaml:
--------------------------------------------------------------------------------
1 | prepare_data.py:
2 | datasets:
3 | lakes_shapefile: https://ge.ch/sitg/geodata/SITG/OPENDATA/3951/SHP_GEO_LAC_LEMAN.zip
4 | parcels_shapefile: https://ge.ch/sitg/geodata/SITG/OPENDATA/8450/SHP_CAD_PARCELLE_MENSU.zip
5 | swimming_pools_shapefile: https://ge.ch/sitg/geodata/SITG/OPENDATA/1836/SHP_CAD_PISCINE.zip
6 | OK_z18_tile_IDs_csv: data/OK_z18_tile_IDs.csv
7 | output_folder: output_GE
8 |
9 | generate_tilesets.py:
10 | debug_mode:
11 | enable: False # sample of tiles
12 | nb_tiles_max: 1000
13 | working_directory: .
14 | datasets:
15 | aoi_tiles: output_GE/aoi_z18_tiles.geojson
16 | ground_truth_labels: output_GE/ground_truth_labels.geojson
17 | other_labels: output_GE/other_labels.geojson
18 | image_source:
19 | type: MIL # supported values: 1. MIL = Map Image Layer 2. WMS 3. XYZ 4. FOLDER
20 | location: https://raster.sitg.ge.ch/arcgis/rest/services/ORTHOPHOTOS_2018_EPSG2056/MapServer
21 | srs: "EPSG:3857"
22 | output_folder: output_GE
23 | tile_size: 256 # per side, in pixels
24 | overwrite: False
25 | n_jobs: 10
26 | COCO_metadata:
27 | year: 2020
28 | version: 1.0
29 | description: 2018 orthophotos w/ Swimming Pool segmentations
30 | contributor: Système d'information du territoire à Genève (SITG)
31 | url: https://ge.ch/sitg
32 | license:
33 | name: Open Data
34 | url: https://ge.ch/sitg/media/sitg/files/documents/conditions_generales_dutilisation_des_donnees_et_produits_du_sitg_en_libre_acces.pdf
35 |
36 | train_model.py:
37 | debug_mode: False
38 | working_directory: output_GE
39 | log_subfolder: logs
40 | sample_tagged_img_subfolder: sample_training_images
41 | COCO_files: # relative paths, w/ respect to the working_folder
42 | trn: COCO_trn.json
43 | val: COCO_val.json
44 | tst: COCO_tst.json
45 | detectron2_config_file: '../detectron2_config_GE.yaml' # path relative to the working_folder
46 | model_weights:
47 | model_zoo_checkpoint_url: "COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml"
48 |
49 | make_detections.py:
50 | working_directory: output_GE
51 | log_subfolder: logs
52 | sample_tagged_img_subfolder: sample_detection_images
53 | COCO_files: # relative paths, w/ respect to the working_folder
54 | trn: COCO_trn.json
55 | val: COCO_val.json
56 | tst: COCO_tst.json
57 | oth: COCO_oth.json
58 | detectron2_config_file: '../detectron2_config_GE.yaml' # path relative to the working_folder
59 | model_weights:
60 | pth_file: 'logs/model_final.pth'
61 | image_metadata_json: img_metadata.json
62 | rdp_simplification: # rdp = Ramer-Douglas-Peucker
63 | enabled: true
64 | epsilon: 0.5 # cf. https://rdp.readthedocs.io/en/latest/
65 | score_lower_threshold: 0.05
66 | remove_det_overlap: False # if several detections overlap (IoU > 0.5), only the one with the highest confidence score is retained
67 |
68 | assess_detections.py:
69 | working_directory: output_GE
70 | datasets:
71 | ground_truth_labels: ground_truth_labels.geojson
72 | other_labels: other_labels.geojson
73 | split_aoi_tiles: split_aoi_tiles.geojson # aoi = Area of Interest
74 | categories: category_ids.json
75 | detections:
76 | trn: trn_detections_at_0dot05_threshold.gpkg
77 | val: val_detections_at_0dot05_threshold.gpkg
78 | tst: tst_detections_at_0dot05_threshold.gpkg
79 | oth: oth_detections_at_0dot05_threshold.gpkg
80 | output_folder: .
81 | metrics_method: micro-average # 1: macro-average ; 3: macro-weighted-average ; 2: micro-average
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/GE/detectron2_config_GE.yaml:
--------------------------------------------------------------------------------
1 | CUDNN_BENCHMARK: false
2 | DATALOADER:
3 | ASPECT_RATIO_GROUPING: true
4 | FILTER_EMPTY_ANNOTATIONS: true
5 | NUM_WORKERS: 4
6 | REPEAT_THRESHOLD: 0.0
7 | SAMPLER_TRAIN: TrainingSampler
8 | DATASETS:
9 | PRECOMPUTED_PROPOSAL_TOPK_TEST: 1000
10 | PRECOMPUTED_PROPOSAL_TOPK_TRAIN: 2000
11 | PROPOSAL_FILES_TEST: []
12 | PROPOSAL_FILES_TRAIN: []
13 | TEST:
14 | - val_dataset
15 | TRAIN:
16 | - trn_dataset
17 | GLOBAL:
18 | HACK: 1.0
19 | INPUT:
20 | CROP:
21 | ENABLED: false
22 | SIZE:
23 | - 0.9
24 | - 0.9
25 | TYPE: relative_range
26 | FORMAT: RGB
27 | MASK_FORMAT: polygon
28 | MAX_SIZE_TEST: 1333
29 | MAX_SIZE_TRAIN: 1333
30 | MIN_SIZE_TEST: 800
31 | MIN_SIZE_TRAIN:
32 | - 640
33 | - 672
34 | - 704
35 | - 736
36 | - 768
37 | - 800
38 | MIN_SIZE_TRAIN_SAMPLING: choice
39 | MODEL:
40 | ANCHOR_GENERATOR:
41 | ANGLES:
42 | - - -90
43 | - 0
44 | - 90
45 | ASPECT_RATIOS:
46 | - - 0.5
47 | - 1.0
48 | - 2.0
49 | NAME: DefaultAnchorGenerator
50 | OFFSET: 0.0
51 | SIZES:
52 | - - 32
53 | - - 64
54 | - - 128
55 | - - 256
56 | - - 512
57 | BACKBONE:
58 | FREEZE_AT: 2
59 | NAME: build_resnet_fpn_backbone
60 | DEVICE: cuda
61 | FPN:
62 | FUSE_TYPE: sum
63 | IN_FEATURES:
64 | - res2
65 | - res3
66 | - res4
67 | - res5
68 | NORM: ''
69 | OUT_CHANNELS: 256
70 | KEYPOINT_ON: false
71 | LOAD_PROPOSALS: false
72 | MASK_ON: true
73 | META_ARCHITECTURE: GeneralizedRCNN
74 | PANOPTIC_FPN:
75 | COMBINE:
76 | ENABLED: true
77 | INSTANCES_CONFIDENCE_THRESH: 0.5
78 | OVERLAP_THRESH: 0.5
79 | STUFF_AREA_LIMIT: 4096
80 | INSTANCE_LOSS_WEIGHT: 1.0
81 | PIXEL_MEAN:
82 | - 103.53
83 | - 116.28
84 | - 123.675
85 | PIXEL_STD:
86 | - 1.0
87 | - 1.0
88 | - 1.0
89 | PROPOSAL_GENERATOR:
90 | MIN_SIZE: 0
91 | NAME: RPN
92 | RESNETS:
93 | DEFORM_MODULATED: false
94 | DEFORM_NUM_GROUPS: 1
95 | DEFORM_ON_PER_STAGE:
96 | - false
97 | - false
98 | - false
99 | - false
100 | DEPTH: 50
101 | NORM: FrozenBN
102 | NUM_GROUPS: 1
103 | OUT_FEATURES:
104 | - res2
105 | - res3
106 | - res4
107 | - res5
108 | RES2_OUT_CHANNELS: 256
109 | RES5_DILATION: 1
110 | STEM_OUT_CHANNELS: 64
111 | STRIDE_IN_1X1: true
112 | WIDTH_PER_GROUP: 64
113 | RETINANET:
114 | BBOX_REG_WEIGHTS:
115 | - 1.0
116 | - 1.0
117 | - 1.0
118 | - 1.0
119 | FOCAL_LOSS_ALPHA: 0.25
120 | FOCAL_LOSS_GAMMA: 2.0
121 | IN_FEATURES:
122 | - p3
123 | - p4
124 | - p5
125 | - p6
126 | - p7
127 | IOU_LABELS:
128 | - 0
129 | - -1
130 | - 1
131 | IOU_THRESHOLDS:
132 | - 0.4
133 | - 0.5
134 | NMS_THRESH_TEST: 0.5
135 | NUM_CLASSES: 80
136 | NUM_CONVS: 4
137 | PRIOR_PROB: 0.01
138 | SCORE_THRESH_TEST: 0.05
139 | SMOOTH_L1_LOSS_BETA: 0.1
140 | TOPK_CANDIDATES_TEST: 1000
141 | ROI_BOX_CASCADE_HEAD:
142 | BBOX_REG_WEIGHTS:
143 | - - 10.0
144 | - 10.0
145 | - 5.0
146 | - 5.0
147 | - - 20.0
148 | - 20.0
149 | - 10.0
150 | - 10.0
151 | - - 30.0
152 | - 30.0
153 | - 15.0
154 | - 15.0
155 | IOUS:
156 | - 0.5
157 | - 0.6
158 | - 0.7
159 | ROI_BOX_HEAD:
160 | BBOX_REG_WEIGHTS:
161 | - 10.0
162 | - 10.0
163 | - 5.0
164 | - 5.0
165 | CLS_AGNOSTIC_BBOX_REG: false
166 | CONV_DIM: 256
167 | FC_DIM: 1024
168 | NAME: FastRCNNConvFCHead
169 | NORM: ''
170 | NUM_CONV: 0
171 | NUM_FC: 2
172 | POOLER_RESOLUTION: 7
173 | POOLER_SAMPLING_RATIO: 0
174 | POOLER_TYPE: ROIAlignV2
175 | SMOOTH_L1_BETA: 0.0
176 | TRAIN_ON_PRED_BOXES: false
177 | ROI_HEADS:
178 | BATCH_SIZE_PER_IMAGE: 1024
179 | IN_FEATURES:
180 | - p2
181 | - p3
182 | - p4
183 | - p5
184 | IOU_LABELS:
185 | - 0
186 | - 1
187 | IOU_THRESHOLDS:
188 | - 0.5
189 | NAME: StandardROIHeads
190 | NMS_THRESH_TEST: 0.5
191 | POSITIVE_FRACTION: 0.25
192 | PROPOSAL_APPEND_GT: true
193 | SCORE_THRESH_TEST: 0.05
194 | ROI_KEYPOINT_HEAD:
195 | CONV_DIMS:
196 | - 512
197 | - 512
198 | - 512
199 | - 512
200 | - 512
201 | - 512
202 | - 512
203 | - 512
204 | LOSS_WEIGHT: 1.0
205 | MIN_KEYPOINTS_PER_IMAGE: 1
206 | NAME: KRCNNConvDeconvUpsampleHead
207 | NORMALIZE_LOSS_BY_VISIBLE_KEYPOINTS: true
208 | NUM_KEYPOINTS: 17
209 | POOLER_RESOLUTION: 14
210 | POOLER_SAMPLING_RATIO: 0
211 | POOLER_TYPE: ROIAlignV2
212 | ROI_MASK_HEAD:
213 | CLS_AGNOSTIC_MASK: false
214 | CONV_DIM: 256
215 | NAME: MaskRCNNConvUpsampleHead
216 | NORM: ''
217 | NUM_CONV: 4
218 | POOLER_RESOLUTION: 14
219 | POOLER_SAMPLING_RATIO: 0
220 | POOLER_TYPE: ROIAlignV2
221 | RPN:
222 | BATCH_SIZE_PER_IMAGE: 256
223 | BBOX_REG_WEIGHTS:
224 | - 1.0
225 | - 1.0
226 | - 1.0
227 | - 1.0
228 | BOUNDARY_THRESH: -1
229 | HEAD_NAME: StandardRPNHead
230 | IN_FEATURES:
231 | - p2
232 | - p3
233 | - p4
234 | - p5
235 | - p6
236 | IOU_LABELS:
237 | - 0
238 | - -1
239 | - 1
240 | IOU_THRESHOLDS:
241 | - 0.3
242 | - 0.7
243 | LOSS_WEIGHT: 1.0
244 | NMS_THRESH: 0.7
245 | POSITIVE_FRACTION: 0.5
246 | POST_NMS_TOPK_TEST: 1000
247 | POST_NMS_TOPK_TRAIN: 1000
248 | PRE_NMS_TOPK_TEST: 1000
249 | PRE_NMS_TOPK_TRAIN: 2000
250 | SMOOTH_L1_BETA: 0.0
251 | SEM_SEG_HEAD:
252 | COMMON_STRIDE: 4
253 | CONVS_DIM: 128
254 | IGNORE_VALUE: 255
255 | IN_FEATURES:
256 | - p2
257 | - p3
258 | - p4
259 | - p5
260 | LOSS_WEIGHT: 1.0
261 | NAME: SemSegFPNHead
262 | NORM: GN
263 | NUM_CLASSES: 54
264 | WEIGHTS: https://dl.fbaipublicfiles.com/detectron2/COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x/137260431/model_final_a54504.pkl
265 | OUTPUT_DIR: logs
266 | SEED: -1
267 | SOLVER:
268 | BASE_LR: 0.005
269 | BIAS_LR_FACTOR: 1.0
270 | CHECKPOINT_PERIOD: 1000
271 | CLIP_GRADIENTS:
272 | CLIP_TYPE: value
273 | CLIP_VALUE: 1.0
274 | ENABLED: false
275 | NORM_TYPE: 2.0
276 | GAMMA: 0.8
277 | IMS_PER_BATCH: 2
278 | LR_SCHEDULER_NAME: WarmupMultiStepLR
279 | MAX_ITER: 6000
280 | MOMENTUM: 0.9
281 | NESTEROV: false
282 | STEPS:
283 | - 2000
284 | - 2500
285 | - 3000
286 | - 3500
287 | - 4000
288 | - 4500
289 | - 5000
290 | - 5500
291 | # - 6000
292 | # - 6500
293 | # - 7000
294 | # - 7500
295 | # - 8000
296 | # - 8500
297 | # - 9000
298 | # - 9500
299 | WARMUP_FACTOR: 0.001
300 | WARMUP_ITERS: 200
301 | WARMUP_METHOD: linear
302 | WEIGHT_DECAY: 0.0001
303 | WEIGHT_DECAY_BIAS: 0.0001
304 | WEIGHT_DECAY_NORM: 0.0
305 | TEST:
306 | AUG:
307 | ENABLED: false
308 | FLIP: true
309 | MAX_SIZE: 4000
310 | MIN_SIZES:
311 | - 400
312 | - 500
313 | - 600
314 | - 700
315 | - 800
316 | - 900
317 | - 1000
318 | - 1100
319 | - 1200
320 | DETECTIONS_PER_IMAGE: 100
321 | EVAL_PERIOD: 200
322 | EXPECTED_RESULTS: []
323 | KEYPOINT_OKS_SIGMAS: []
324 | PRECISE_BN:
325 | ENABLED: false
326 | NUM_ITER: 200
327 | VERSION: 2
328 | VIS_PERIOD: 0
329 |
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/GE/prepare_data.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import time
7 | import argparse
8 | import yaml
9 | import requests
10 | import geopandas as gpd
11 | import pandas as pd
12 |
13 | from loguru import logger
14 |
15 | sys.path.insert(1, '../../..')
16 | from helpers.misc import format_logger
17 |
18 | logger = format_logger(logger)
19 |
20 |
21 | if __name__ == "__main__":
22 |
23 | tic = time.time()
24 | logger.info('Starting...')
25 |
26 | parser = argparse.ArgumentParser(description="This script prepares datasets for the Geneva's Swimming Pools detection task.")
27 | parser.add_argument('config_file', type=str, help='a YAML config file')
28 | args = parser.parse_args()
29 |
30 | logger.info(f"Using {args.config_file} as config file.")
31 |
32 | with open(args.config_file) as fp:
33 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
34 |
35 | OUTPUT_DIR = cfg['output_folder']
36 | LAKES_SHPFILE = cfg['datasets']['lakes_shapefile']
37 | PARCELS_SHPFILE = cfg['datasets']['parcels_shapefile']
38 | SWIMMING_POOLS_SHPFILE = cfg['datasets']['swimming_pools_shapefile']
39 | OK_TILE_IDS_CSV = cfg['datasets']['OK_z18_tile_IDs_csv']
40 | ZOOM_LEVEL = 18 # this is hard-coded 'cause we only know "OK tile IDs" for this zoom level
41 |
42 | # let's make the output directory in case it doesn't exist
43 | if not os.path.exists(OUTPUT_DIR):
44 | os.makedirs(OUTPUT_DIR)
45 |
46 | written_files = []
47 |
48 | # ------ (Down)loading datasets
49 |
50 | dataset_dict = {}
51 |
52 | for dataset in ['lakes', 'parcels', 'swimming_pools']:
53 |
54 | shpfile_name = eval(f'{dataset.upper()}_SHPFILE').split('/')[-1]
55 | shpfile_path = os.path.join(OUTPUT_DIR, shpfile_name)
56 |
57 | if eval(f'{dataset.upper()}_SHPFILE').startswith('http'):
58 |
59 | logger.info(f"Downloading the {dataset.replace('_', ' ')} dataset...")
60 | r = requests.get(eval(f'{dataset.upper()}_SHPFILE'), timeout=30)
61 | with open(shpfile_path, 'wb') as f:
62 | f.write(r.content)
63 |
64 | written_files.append(shpfile_path)
65 | logger.success(f"...done. A file was written: {shpfile_path}")
66 |
67 | logger.info(f"Loading the {dataset.replace('_', ' ')} dataset as a GeoPandas DataFrame...")
68 | dataset_dict[dataset] = gpd.read_file(f'zip://{shpfile_path}')
69 | logger.success(f"...done. {len(dataset_dict[dataset])} records were found.")
70 |
71 | dataset_dict['swimming_pools']['CATEGORY'] = "swimming pool"
72 | dataset_dict['swimming_pools']['SUPERCATEGORY'] = "facility"
73 |
74 | # ------ Computing the Area of Interest (AoI) = cadastral parcels - Léman lake
75 |
76 | logger.info("Computing the Area of Interest (AoI)...")
77 |
78 | # N.B.:
79 | # it's faster to first compute Slippy Map Tiles (cf. https://developers.planet.com/tutorials/slippy-maps-101/),
80 | # then suppress the tiles which "fall" within the Léman lake.
81 | # We rely on supermercado, mercantile and fiona for the computation of Slippy Map Tiles.
82 |
83 | # lake_gdf
84 | l_gdf = dataset_dict['lakes'].copy()
85 | # parcels_gdf
86 | p_gdf = dataset_dict['parcels'].copy()
87 |
88 | PARCELS_TILES_GEOJSON_FILE = os.path.join(OUTPUT_DIR, f"parcels_z{ZOOM_LEVEL}_tiles.geojson")
89 |
90 | if not os.path.isfile(PARCELS_TILES_GEOJSON_FILE):
91 | logger.info("Exporting the parcels dataset to a GeoJSON file...")
92 | PARCELS_GEOJSON_FILE = os.path.join(OUTPUT_DIR, 'parcels.geojson')
93 | p_gdf[['geometry']].to_crs(epsg=4326).to_file(PARCELS_GEOJSON_FILE, driver='GeoJSON')
94 | written_files.append(PARCELS_GEOJSON_FILE)
95 | logger.success(f"...done. The {PARCELS_GEOJSON_FILE} was written.")
96 |
97 | print()
98 | logger.warning(f"You should now open a Linux shell and run the following command from the working directory (./{OUTPUT_DIR}), then run this script again:")
99 | logger.warning(f"cat parcels.geojson | supermercado burn {ZOOM_LEVEL} | mercantile shapes | fio collect > parcels_z{ZOOM_LEVEL}_tiles.geojson")
100 | sys.exit(0)
101 |
102 | else:
103 | parcels_tiles_gdf = gpd.read_file(PARCELS_TILES_GEOJSON_FILE)
104 |
105 | # parcels tiles falling within the lake
106 | tiles_to_remove_gdf = gpd.sjoin(parcels_tiles_gdf.to_crs(epsg=l_gdf.crs.to_epsg()), l_gdf[l_gdf.NOM == 'Léman'], how='right', predicate='within')
107 |
108 | aoi_tiles_gdf = parcels_tiles_gdf[ ~parcels_tiles_gdf.index.isin(tiles_to_remove_gdf.index_left) ]
109 | assert ( len(aoi_tiles_gdf.drop_duplicates(subset='id')) == len(aoi_tiles_gdf) ) # make sure there are no duplicates
110 |
111 | AOI_TILES_GEOJSON_FILE = os.path.join(OUTPUT_DIR, f'aoi_z{ZOOM_LEVEL}_tiles.geojson')
112 | aoi_tiles_gdf.to_crs(epsg=4326).to_file(AOI_TILES_GEOJSON_FILE, driver='GeoJSON')
113 | written_files.append(AOI_TILES_GEOJSON_FILE)
114 |
115 |
116 | # ------- Splitting labels into two groups: ground truth (those intersecting the "OK" tileset) and other
117 |
118 | # OK tiles: the subset of tiles containing neither false positives nor false negatives
119 | OK_ids = pd.read_csv(OK_TILE_IDS_CSV)
120 | OK_tiles_gdf = aoi_tiles_gdf[aoi_tiles_gdf.id.isin(OK_ids.id)]
121 |
122 | OK_TILES_GEOJSON = os.path.join(OUTPUT_DIR, 'OK_tiles.geojson')
123 | OK_tiles_gdf.to_crs(epsg=4326).to_file(OK_TILES_GEOJSON, driver='GeoJSON')
124 | written_files.append(OK_TILES_GEOJSON)
125 |
126 | labels_gdf = dataset_dict['swimming_pools'].copy()
127 | labels_gdf = labels_gdf.to_crs(epsg=4326)
128 |
129 | # Ground Truth Labels = Labels intersecting OK tiles
130 | try:
131 | assert( labels_gdf.crs == OK_tiles_gdf.crs ), f"CRS mismatching: labels' CRS = {labels_gdf.crs} != OK_tiles' CRS = {OK_tiles_gdf.crs}"
132 | except Exception as e:
133 | logger.critical(e)
134 | sys.exit(1)
135 |
136 | GT_labels_gdf = gpd.sjoin(labels_gdf, OK_tiles_gdf, how='inner', predicate='intersects')
137 | # the following two lines make sure that no swimming pool is counted more than once in case it intersects multiple tiles
138 | GT_labels_gdf = GT_labels_gdf[labels_gdf.columns]
139 | GT_labels_gdf.drop_duplicates(inplace=True)
140 | OTH_labels_gdf = labels_gdf[ ~labels_gdf.index.isin(GT_labels_gdf.index)]
141 |
142 | try:
143 | assert( len(labels_gdf) == len(GT_labels_gdf) + len(OTH_labels_gdf) ),\
144 | f"Something went wrong when splitting labels into Ground Truth Labels and Other Labels. Total no. of labels = {len(labels_gdf)}; no. of Ground Truth Labels = {len(GT_labels_gdf)}; no. of Other Labels = {len(OTH_labels_gdf)}"
145 | except Exception as e:
146 | logger.critical(e)
147 | sys.exit(1)
148 |
149 | GT_LABELS_GEOJSON = os.path.join(OUTPUT_DIR, 'ground_truth_labels.geojson')
150 | OTH_LABELS_GEOJSON = os.path.join(OUTPUT_DIR, 'other_labels.geojson')
151 |
152 | GT_labels_gdf.to_crs(epsg=4326).to_file(GT_LABELS_GEOJSON, driver='GeoJSON')
153 | written_files.append(GT_LABELS_GEOJSON)
154 | OTH_labels_gdf.to_crs(epsg=4326).to_file(OTH_LABELS_GEOJSON, driver='GeoJSON')
155 | written_files.append(OTH_LABELS_GEOJSON)
156 |
157 | print()
158 | logger.info("The following files were written. Let's check them out!")
159 | for written_file in written_files:
160 | logger.info(written_file)
161 | print()
162 |
163 | toc = time.time()
164 | logger.success(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
165 |
166 | sys.stderr.flush()
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/README.md:
--------------------------------------------------------------------------------
1 | # Example: detecting swimming pools over the Canton of Neuchâtel
2 |
3 | A sample working setup is here provided, allowing the end-user to detect swimming pools over the Canton of Neuchâtel. It is made up by the following assets:
4 |
5 | * ready-to-use configuration files, namely `config_NE.yaml` and `detectron2_config_NE.yaml`.
6 | * Supplementary data (`data/*`), *i.e.*
7 | * geographical sectors covering ground-truth data;
8 | * other (non ground-truth) sectors;
9 | * ground-truth labels;
10 | * other labels.
11 | * A data preparation script (`prepare_data.py`), producing files to be used as input to the `generate_tilesets` stage.
12 |
13 | The workflow can be run end-to-end by issuing the following list of commands, from the root folder of this GitHub repository:
14 |
15 | ```
16 | $ sudo chown -R 65534:65534 examples
17 | $ docker compose run --rm -it stdl-objdet
18 | nobody@:/app# cd examples/swimming-pool-detection/NE
19 | nobody@:/app# python prepare_data.py config_NE.yaml
20 | nobody@:/app# cd output_NE && cat aoi.geojson | supermercado burn 18 | mercantile shapes | fio collect > aoi_z18_tiles.geojson && cd -
21 | nobody@:/app# python prepare_data.py config_NE.yaml
22 | nobody@:/app# stdl-objdet generate_tilesets config_NE.yaml
23 | nobody@:/app# stdl-objdet train_model config_NE.yaml
24 | nobody@:/app# stdl-objdet make_detections config_NE.yaml
25 | nobody@:/app# stdl-objdet assess_detections config_NE.yaml
26 | nobody@:/app# exit
27 | $ sudo chmod -R a+w examples
28 | ```
29 |
30 | We strongly encourage the end-user to review the provided `config_NE.yaml` file as well as the various output files, a list of which is printed by each script before exiting.
31 |
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/config_NE.yaml:
--------------------------------------------------------------------------------
1 | prepare_data.py:
2 | datasets:
3 | ground_truth_sectors_shapefile: data/Ground_truth_sectors.shp
4 | other_sectors_shapefile: data/Other_sector.shp
5 | ground_truth_swimming_pools_shapefile: data/Ground_truth_swimming_pools.shp
6 | other_swimming_pools_shapefile: data/Other_swimming_pools.shp
7 | zoom_level: 18
8 | output_folder: output_NE
9 |
10 | generate_tilesets.py:
11 | debug_mode:
12 | enable: False # sample of tiles
13 | nb_tiles_max: 100
14 | working_directory: output_NE
15 | datasets:
16 | aoi_tiles: aoi_z18_tiles.geojson
17 | ground_truth_labels: ground_truth_labels.geojson
18 | other_labels: other_labels.geojson
19 | image_source:
20 | type: WMS # supported values: 1. MIL = Map Image Layer 2. WMS 3. XYZ 4. FOLDER
21 | location: https://sitn.ne.ch/mapproxy95/service
22 | layers: ortho2019
23 | srs: "EPSG:3857"
24 | # empty_tiles: # add empty tiles to datasets
25 | # tiles_frac: 0.5 # fraction (relative to the number of tiles intersecting labels) of empty tiles to add
26 | # frac_trn: 0.75 # fraction of empty tiles to add to the trn dataset, then the remaining tiles will be split in 2 and added to tst and val datasets
27 | # keep_oth_tiles: True # keep tiles in oth dataset not intersecting oth labels
28 | output_folder: .
29 | tile_size: 256 # per side, in pixels
30 | overwrite: True
31 | n_jobs: 10
32 | COCO_metadata:
33 | year: 2020
34 | version: 1.0
35 | description: 2019 orthophotos w/ Swimming Pool segmentations
36 | contributor: Système d'information du territoire Neuchâtelois (SITN)
37 | url: https://sitn.ne.ch
38 | license:
39 | name: Unknown
40 | url:
41 |
42 | train_model.py:
43 | working_directory: output_NE
44 | log_subfolder: logs
45 | sample_tagged_img_subfolder: sample_training_images
46 | COCO_files: # relative paths, w/ respect to the working_folder
47 | trn: COCO_trn.json
48 | val: COCO_val.json
49 | tst: COCO_tst.json
50 | detectron2_config_file: '../detectron2_config_NE.yaml' # path relative to the working_folder
51 | model_weights:
52 | model_zoo_checkpoint_url: "COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x.yaml"
53 |
54 | make_detections.py:
55 | working_directory: output_NE
56 | log_subfolder: logs
57 | sample_tagged_img_subfolder: sample_detection_images
58 | COCO_files: # relative paths, w/ respect to the working_folder
59 | trn: COCO_trn.json
60 | val: COCO_val.json
61 | tst: COCO_tst.json
62 | oth: COCO_oth.json
63 | detectron2_config_file: '../detectron2_config_NE.yaml' # path relative to the working_folder
64 | model_weights:
65 | pth_file: './logs/model_final.pth'
66 | image_metadata_json: img_metadata.json
67 | rdp_simplification: # rdp = Ramer-Douglas-Peucker
68 | enabled: true
69 | epsilon: 0.5 # cf. https://rdp.readthedocs.io/en/latest/
70 | score_lower_threshold: 0.05
71 | remove_det_overlap: False # if several detections overlap (IoU > 0.5), only the one with the highest confidence score is retained
72 |
73 | assess_detections.py:
74 | working_directory: output_NE
75 | datasets:
76 | ground_truth_labels: ground_truth_labels.geojson
77 | other_labels: other_labels.geojson
78 | split_aoi_tiles: split_aoi_tiles.geojson # aoi = Area of Interest
79 | categories: category_ids.json
80 | detections:
81 | trn: trn_detections_at_0dot05_threshold.gpkg
82 | val: val_detections_at_0dot05_threshold.gpkg
83 | tst: tst_detections_at_0dot05_threshold.gpkg
84 | oth: oth_detections_at_0dot05_threshold.gpkg
85 | output_folder: .
86 | metrics_method: micro-average # 1: macro-average ; 3: macro-weighted-average ; 2: micro-average
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.cpg:
--------------------------------------------------------------------------------
1 | ISO-8859-1
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.dbf:
--------------------------------------------------------------------------------
1 | z A FID N
0 1 2 3 4 5
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.shp
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Ground_truth_sectors.shx
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.cpg:
--------------------------------------------------------------------------------
1 | ISO-8859-1
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.dbf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.dbf
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.shp
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Ground_truth_swimming_pools.shx
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_sector.cpg:
--------------------------------------------------------------------------------
1 | ISO-8859-1
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_sector.dbf:
--------------------------------------------------------------------------------
1 | z A FID N
0
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_sector.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_sector.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Other_sector.shp
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_sector.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Other_sector.shx
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_swimming_pools.cpg:
--------------------------------------------------------------------------------
1 | ISO-8859-1
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_swimming_pools.prj:
--------------------------------------------------------------------------------
1 | PROJCS["CH1903+_LV95",GEOGCS["GCS_CH1903+",DATUM["D_CH1903+",SPHEROID["Bessel_1841",6377397.155,299.1528128]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Hotine_Oblique_Mercator_Azimuth_Center"],PARAMETER["False_Easting",2600000.0],PARAMETER["False_Northing",1200000.0],PARAMETER["Scale_Factor",1.0],PARAMETER["Azimuth",90.0],PARAMETER["Longitude_Of_Center",7.43958333333333],PARAMETER["Latitude_Of_Center",46.9524055555556],UNIT["Meter",1.0]]
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_swimming_pools.shp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Other_swimming_pools.shp
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/Other_swimming_pools.shx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/examples/swimming-pool-detection/NE/data/Other_swimming_pools.shx
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/data/README.md:
--------------------------------------------------------------------------------
1 | # Sample Data provided by the Canton of Neuchâtel
2 |
3 | We provide data covering a part of the Canton of Neuchâtel, split into two non-overlapping regions, which together form the Area of Interest (AoI):
4 |
5 | 1. as suggested by the filename, the `Ground_truth_sectors` dataset includes sectors for which all the relevant swimming pools visible in the 2019 orthophoto (cf. the `ortho2019` raster layer available through [this Web Map Service](https://sitn.ne.ch/mapproxy95/service?request=GetCapabilities)) were digitized by domain experts. Polygons related to these swimming pools can be found in the `Ground_truth_swimming_pools` dataset.
6 |
7 | 2. The `Other_sector` file provides a sector to be used for inference. The `Other_swimming_pools` file includes a collection of candidate swimming pools, which is neither exhaustive nor totally reliable.
8 |
9 | All the datasets in this folder were originally provided by the Canton of Neuchâtel, then curated by the STDL team. __This data can be used for research purposes only. Commercial use is prohibited.__
10 |
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/detectron2_config_NE.yaml:
--------------------------------------------------------------------------------
1 | CUDNN_BENCHMARK: false
2 | DATALOADER:
3 | ASPECT_RATIO_GROUPING: true
4 | FILTER_EMPTY_ANNOTATIONS: true
5 | NUM_WORKERS: 4
6 | REPEAT_THRESHOLD: 0.0
7 | SAMPLER_TRAIN: TrainingSampler
8 | DATASETS:
9 | PRECOMPUTED_PROPOSAL_TOPK_TEST: 1000
10 | PRECOMPUTED_PROPOSAL_TOPK_TRAIN: 2000
11 | PROPOSAL_FILES_TEST: []
12 | PROPOSAL_FILES_TRAIN: []
13 | TEST:
14 | - val_dataset
15 | TRAIN:
16 | - trn_dataset
17 | GLOBAL:
18 | HACK: 1.0
19 | INPUT:
20 | CROP:
21 | ENABLED: false
22 | SIZE:
23 | - 0.9
24 | - 0.9
25 | TYPE: relative_range
26 | FORMAT: RGB
27 | MASK_FORMAT: polygon
28 | MAX_SIZE_TEST: 1333
29 | MAX_SIZE_TRAIN: 1333
30 | MIN_SIZE_TEST: 800
31 | MIN_SIZE_TRAIN:
32 | - 640
33 | - 672
34 | - 704
35 | - 736
36 | - 768
37 | - 800
38 | MIN_SIZE_TRAIN_SAMPLING: choice
39 | MODEL:
40 | ANCHOR_GENERATOR:
41 | ANGLES:
42 | - - -90
43 | - 0
44 | - 90
45 | ASPECT_RATIOS:
46 | - - 0.5
47 | - 1.0
48 | - 2.0
49 | NAME: DefaultAnchorGenerator
50 | OFFSET: 0.0
51 | SIZES:
52 | - - 32
53 | - - 64
54 | - - 128
55 | - - 256
56 | - - 512
57 | BACKBONE:
58 | FREEZE_AT: 2
59 | NAME: build_resnet_fpn_backbone
60 | DEVICE: cuda
61 | FPN:
62 | FUSE_TYPE: sum
63 | IN_FEATURES:
64 | - res2
65 | - res3
66 | - res4
67 | - res5
68 | NORM: ''
69 | OUT_CHANNELS: 256
70 | KEYPOINT_ON: false
71 | LOAD_PROPOSALS: false
72 | MASK_ON: true
73 | META_ARCHITECTURE: GeneralizedRCNN
74 | PANOPTIC_FPN:
75 | COMBINE:
76 | ENABLED: true
77 | INSTANCES_CONFIDENCE_THRESH: 0.5
78 | OVERLAP_THRESH: 0.5
79 | STUFF_AREA_LIMIT: 4096
80 | INSTANCE_LOSS_WEIGHT: 1.0
81 | PIXEL_MEAN:
82 | - 103.53
83 | - 116.28
84 | - 123.675
85 | PIXEL_STD:
86 | - 1.0
87 | - 1.0
88 | - 1.0
89 | PROPOSAL_GENERATOR:
90 | MIN_SIZE: 0
91 | NAME: RPN
92 | RESNETS:
93 | DEFORM_MODULATED: false
94 | DEFORM_NUM_GROUPS: 1
95 | DEFORM_ON_PER_STAGE:
96 | - false
97 | - false
98 | - false
99 | - false
100 | DEPTH: 50
101 | NORM: FrozenBN
102 | NUM_GROUPS: 1
103 | OUT_FEATURES:
104 | - res2
105 | - res3
106 | - res4
107 | - res5
108 | RES2_OUT_CHANNELS: 256
109 | RES5_DILATION: 1
110 | STEM_OUT_CHANNELS: 64
111 | STRIDE_IN_1X1: true
112 | WIDTH_PER_GROUP: 64
113 | RETINANET:
114 | BBOX_REG_WEIGHTS:
115 | - 1.0
116 | - 1.0
117 | - 1.0
118 | - 1.0
119 | FOCAL_LOSS_ALPHA: 0.25
120 | FOCAL_LOSS_GAMMA: 2.0
121 | IN_FEATURES:
122 | - p3
123 | - p4
124 | - p5
125 | - p6
126 | - p7
127 | IOU_LABELS:
128 | - 0
129 | - -1
130 | - 1
131 | IOU_THRESHOLDS:
132 | - 0.4
133 | - 0.5
134 | NMS_THRESH_TEST: 0.5
135 | NUM_CLASSES: 80
136 | NUM_CONVS: 4
137 | PRIOR_PROB: 0.01
138 | SCORE_THRESH_TEST: 0.05
139 | SMOOTH_L1_LOSS_BETA: 0.1
140 | TOPK_CANDIDATES_TEST: 1000
141 | ROI_BOX_CASCADE_HEAD:
142 | BBOX_REG_WEIGHTS:
143 | - - 10.0
144 | - 10.0
145 | - 5.0
146 | - 5.0
147 | - - 20.0
148 | - 20.0
149 | - 10.0
150 | - 10.0
151 | - - 30.0
152 | - 30.0
153 | - 15.0
154 | - 15.0
155 | IOUS:
156 | - 0.5
157 | - 0.6
158 | - 0.7
159 | ROI_BOX_HEAD:
160 | BBOX_REG_WEIGHTS:
161 | - 10.0
162 | - 10.0
163 | - 5.0
164 | - 5.0
165 | CLS_AGNOSTIC_BBOX_REG: false
166 | CONV_DIM: 256
167 | FC_DIM: 1024
168 | NAME: FastRCNNConvFCHead
169 | NORM: ''
170 | NUM_CONV: 0
171 | NUM_FC: 2
172 | POOLER_RESOLUTION: 7
173 | POOLER_SAMPLING_RATIO: 0
174 | POOLER_TYPE: ROIAlignV2
175 | SMOOTH_L1_BETA: 0.0
176 | TRAIN_ON_PRED_BOXES: false
177 | ROI_HEADS:
178 | BATCH_SIZE_PER_IMAGE: 1024
179 | IN_FEATURES:
180 | - p2
181 | - p3
182 | - p4
183 | - p5
184 | IOU_LABELS:
185 | - 0
186 | - 1
187 | IOU_THRESHOLDS:
188 | - 0.5
189 | NAME: StandardROIHeads
190 | NMS_THRESH_TEST: 0.5
191 | NUM_CLASSES: 1
192 | POSITIVE_FRACTION: 0.25
193 | PROPOSAL_APPEND_GT: true
194 | SCORE_THRESH_TEST: 0.05
195 | ROI_KEYPOINT_HEAD:
196 | CONV_DIMS:
197 | - 512
198 | - 512
199 | - 512
200 | - 512
201 | - 512
202 | - 512
203 | - 512
204 | - 512
205 | LOSS_WEIGHT: 1.0
206 | MIN_KEYPOINTS_PER_IMAGE: 1
207 | NAME: KRCNNConvDeconvUpsampleHead
208 | NORMALIZE_LOSS_BY_VISIBLE_KEYPOINTS: true
209 | NUM_KEYPOINTS: 17
210 | POOLER_RESOLUTION: 14
211 | POOLER_SAMPLING_RATIO: 0
212 | POOLER_TYPE: ROIAlignV2
213 | ROI_MASK_HEAD:
214 | CLS_AGNOSTIC_MASK: false
215 | CONV_DIM: 256
216 | NAME: MaskRCNNConvUpsampleHead
217 | NORM: ''
218 | NUM_CONV: 4
219 | POOLER_RESOLUTION: 14
220 | POOLER_SAMPLING_RATIO: 0
221 | POOLER_TYPE: ROIAlignV2
222 | RPN:
223 | BATCH_SIZE_PER_IMAGE: 256
224 | BBOX_REG_WEIGHTS:
225 | - 1.0
226 | - 1.0
227 | - 1.0
228 | - 1.0
229 | BOUNDARY_THRESH: -1
230 | HEAD_NAME: StandardRPNHead
231 | IN_FEATURES:
232 | - p2
233 | - p3
234 | - p4
235 | - p5
236 | - p6
237 | IOU_LABELS:
238 | - 0
239 | - -1
240 | - 1
241 | IOU_THRESHOLDS:
242 | - 0.3
243 | - 0.7
244 | LOSS_WEIGHT: 1.0
245 | NMS_THRESH: 0.7
246 | POSITIVE_FRACTION: 0.5
247 | POST_NMS_TOPK_TEST: 1000
248 | POST_NMS_TOPK_TRAIN: 1000
249 | PRE_NMS_TOPK_TEST: 1000
250 | PRE_NMS_TOPK_TRAIN: 2000
251 | SMOOTH_L1_BETA: 0.0
252 | SEM_SEG_HEAD:
253 | COMMON_STRIDE: 4
254 | CONVS_DIM: 128
255 | IGNORE_VALUE: 255
256 | IN_FEATURES:
257 | - p2
258 | - p3
259 | - p4
260 | - p5
261 | LOSS_WEIGHT: 1.0
262 | NAME: SemSegFPNHead
263 | NORM: GN
264 | NUM_CLASSES: 54
265 | WEIGHTS: https://dl.fbaipublicfiles.com/detectron2/COCO-InstanceSegmentation/mask_rcnn_R_50_FPN_1x/137260431/model_final_a54504.pkl
266 | OUTPUT_DIR: logs
267 | SEED: -1
268 | SOLVER:
269 | BASE_LR: 0.005
270 | BIAS_LR_FACTOR: 1.0
271 | CHECKPOINT_PERIOD: 1000
272 | CLIP_GRADIENTS:
273 | CLIP_TYPE: value
274 | CLIP_VALUE: 1.0
275 | ENABLED: false
276 | NORM_TYPE: 2.0
277 | GAMMA: 0.8
278 | IMS_PER_BATCH: 2
279 | LR_SCHEDULER_NAME: WarmupMultiStepLR
280 | MAX_ITER: 6000
281 | MOMENTUM: 0.9
282 | NESTEROV: false
283 | STEPS:
284 | - 2000
285 | - 2500
286 | - 3000
287 | - 3500
288 | - 4000
289 | - 4500
290 | - 5000
291 | - 5500
292 | WARMUP_FACTOR: 0.001
293 | WARMUP_ITERS: 200
294 | WARMUP_METHOD: linear
295 | WEIGHT_DECAY: 0.0001
296 | WEIGHT_DECAY_BIAS: 0.0001
297 | WEIGHT_DECAY_NORM: 0.0
298 | TEST:
299 | AUG:
300 | ENABLED: false
301 | FLIP: true
302 | MAX_SIZE: 4000
303 | MIN_SIZES:
304 | - 400
305 | - 500
306 | - 600
307 | - 700
308 | - 800
309 | - 900
310 | - 1000
311 | - 1100
312 | - 1200
313 | DETECTIONS_PER_IMAGE: 100
314 | EVAL_PERIOD: 200
315 | EXPECTED_RESULTS: []
316 | KEYPOINT_OKS_SIGMAS: []
317 | PRECISE_BN:
318 | ENABLED: false
319 | NUM_ITER: 200
320 | VERSION: 2
321 | VIS_PERIOD: 0
322 |
--------------------------------------------------------------------------------
/examples/swimming-pool-detection/NE/prepare_data.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import time
7 | import argparse
8 | import yaml
9 | import geopandas as gpd
10 | import pandas as pd
11 |
12 | from loguru import logger
13 |
14 |
15 | if __name__ == "__main__":
16 |
17 |
18 | tic = time.time()
19 | logger.info('Starting...')
20 |
21 | parser = argparse.ArgumentParser(description="This script prepares datasets for the Neuchâtel's Swimming Pools detection task.")
22 | parser.add_argument('config_file', type=str, help='a YAML config file')
23 | args = parser.parse_args()
24 |
25 | logger.info(f"Using {args.config_file} as config file.")
26 |
27 | with open(args.config_file) as fp:
28 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
29 |
30 | OUTPUT_DIR = cfg['output_folder']
31 | # sectors
32 | GROUND_TRUTH_SECTORS_SHPFILE = cfg['datasets']['ground_truth_sectors_shapefile']
33 | OTHER_SECTORS_SHPFILE = cfg['datasets']['other_sectors_shapefile']
34 | # swimming pools
35 | GROUND_TRUTH_SWIMMING_POOLS_SHPFILE = cfg['datasets']['ground_truth_swimming_pools_shapefile']
36 | OTHER_SWIMMING_POOLS_SHPFILE = cfg['datasets']['other_swimming_pools_shapefile']
37 | ZOOM_LEVEL = cfg['zoom_level']
38 |
39 | # let's make the output directory in case it doesn't exist
40 | if not os.path.exists(OUTPUT_DIR):
41 | os.makedirs(OUTPUT_DIR)
42 |
43 | written_files = []
44 |
45 |
46 | # ------ Loading datasets
47 |
48 | dataset_dict = {}
49 |
50 | for dataset in [
51 | 'ground_truth_sectors',
52 | 'other_sectors',
53 | 'ground_truth_swimming_pools',
54 | 'other_swimming_pools']:
55 |
56 | shpfile = eval(f'{dataset.upper()}_SHPFILE')#.split('/')[-1]
57 |
58 | logger.info(f"Loading the {dataset} dataset as a GeoPandas DataFrame...")
59 | dataset_dict[dataset] = gpd.read_file(f'{shpfile}')
60 | logger.success(f"...done. {len(dataset_dict[dataset])} records were found.")
61 |
62 |
63 | # ------ Computing the Area of Interest (AoI)
64 |
65 | aoi_gdf = pd.concat([
66 | dataset_dict['ground_truth_sectors'],
67 | dataset_dict['other_sectors']
68 | ])
69 |
70 | aoi_gdf.drop_duplicates(inplace=True)
71 |
72 | AOI_GEOJSON = os.path.join(OUTPUT_DIR, "aoi.geojson")
73 | try:
74 | aoi_gdf.to_crs(epsg=4326).to_file(AOI_GEOJSON, driver='GeoJSON', encoding='utf-8')
75 | written_files.append(AOI_GEOJSON)
76 | except Exception as e:
77 | logger.error(f"Could not write to file {AOI_GEOJSON}. Exception: {e}")
78 |
79 | AOI_TILES_GEOJSON = os.path.join(OUTPUT_DIR, f"aoi_z{ZOOM_LEVEL}_tiles.geojson")
80 |
81 | if not os.path.isfile(AOI_TILES_GEOJSON):
82 | print()
83 | logger.warning(f"You should now open a Linux shell and run the following command from the working directory (./{OUTPUT_DIR}), then run this script again:")
84 | logger.warning(f"cat aoi.geojson | supermercado burn {ZOOM_LEVEL} | mercantile shapes | fio collect > aoi_z{ZOOM_LEVEL}_tiles.geojson")
85 | sys.exit(0)
86 |
87 | else:
88 | logger.info("Loading AoI tiles as a GeoPandas DataFrame...")
89 | aoi_tiles_gdf = gpd.read_file(AOI_TILES_GEOJSON)
90 | logger.success(f"...done. {len(aoi_tiles_gdf)} records were found.")
91 |
92 |
93 | assert ( len(aoi_tiles_gdf.drop_duplicates(subset='id')) == len(aoi_tiles_gdf) ) # make sure there are no duplicates
94 |
95 | # ------ Adding category and supercategory
96 |
97 | dataset_dict['ground_truth_swimming_pools'] = dataset_dict['ground_truth_swimming_pools'].assign(CATEGORY="pool", SUPERCATEGORY="facility")
98 | dataset_dict['other_swimming_pools'] = dataset_dict['other_swimming_pools'].assign(CATEGORY="pool", SUPERCATEGORY="facility")
99 |
100 | # ------ Exporting labels to GeoJSON
101 |
102 | GT_LABELS_GEOJSON = os.path.join(OUTPUT_DIR, 'ground_truth_labels.geojson')
103 | OTH_LABELS_GEOJSON = os.path.join(OUTPUT_DIR, 'other_labels.geojson')
104 |
105 | dataset_dict['ground_truth_swimming_pools'].to_crs(epsg=4326).to_file(GT_LABELS_GEOJSON, driver='GeoJSON')
106 | written_files.append(GT_LABELS_GEOJSON)
107 | dataset_dict['other_swimming_pools'].to_crs(epsg=4326).to_file(OTH_LABELS_GEOJSON, driver='GeoJSON')
108 | written_files.append(OTH_LABELS_GEOJSON)
109 |
110 | print()
111 | logger.info("The following files were written. Let's check them out!")
112 | for written_file in written_files:
113 | logger.info(written_file)
114 | print()
115 |
116 | toc = time.time()
117 | logger.success(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
118 |
119 | sys.stderr.flush()
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/helpers/COCO.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import json
7 | import numpy as np
8 | import logging
9 |
10 | from copy import deepcopy
11 | from datetime import datetime, date
12 | from PIL import Image
13 |
14 | pil_logger = logging.getLogger('PIL')
15 | pil_logger.setLevel(logging.INFO)
16 |
17 |
18 | class MissingImageIdException(Exception):
19 | "Raised when an annotation is lacking the image ID field"
20 | pass
21 |
22 |
23 | class MissingCategoryIdException(Exception):
24 | "Raised when an annotation is lacking the category ID field"
25 | pass
26 |
27 |
28 | class LicenseIdNotFoundException(Exception):
29 | "Raised when a given license ID is not found"
30 | pass
31 |
32 |
33 | class COCO:
34 | # cf. http://cocodataset.org/#format-data
35 |
36 | def __init__(self):
37 |
38 | self.info = ""
39 | self.images = []
40 | self.annotations = []
41 | self.licenses = []
42 | self.categories = []
43 | self.images = []
44 |
45 | self._licenses_dict = {}
46 | self._categories_dict = {}
47 | self._annotations_dict = {}
48 | self._images_dict = {}
49 |
50 | return None
51 |
52 | def copy(self):
53 |
54 | copied_self = deepcopy(self)
55 |
56 | return copied_self
57 |
58 | def set_info(self,
59 | year: int,
60 | version: str,
61 | description: str,
62 | contributor: str,
63 | url: str,
64 | date_created: datetime=None):
65 |
66 | if date_created == None:
67 | date_created = date.today()
68 |
69 | info = {"year": year,
70 | "version": version,
71 | "description": description,
72 | "contributor": contributor,
73 | "url": url,
74 | "date_created": date_created,
75 | }
76 |
77 | self.info = info
78 |
79 | return self
80 |
81 |
82 | def annotation(self,
83 | image_id: int,
84 | category_id: int,
85 | segmentation: list,
86 | iscrowd: int,
87 | annotation_id: int=None):
88 |
89 | _annotation = {
90 | "image_id": image_id,
91 | "category_id": category_id,
92 | "segmentation": segmentation,
93 | #"area": area,
94 | #"bbox": bbox, #[x,y,width,height],
95 | "iscrowd": iscrowd,
96 | }
97 |
98 | if annotation_id != None:
99 | _annotation['id'] = annotation_id
100 |
101 | # init
102 | _annotation['area'] = 0
103 | xmin = np.inf
104 | xmax = -np.inf
105 | ymin = np.inf
106 | ymax = -np.inf
107 |
108 | for seg in segmentation:
109 |
110 | xx = [x for idx, x in enumerate(seg) if idx % 2 == 0]
111 | yy = [x for idx, x in enumerate(seg) if idx % 2 == 1]
112 |
113 | xmin = np.min([xmin, np.min(xx)])
114 | xmax = np.max([xmax, np.max(xx)])
115 |
116 | ymin = np.min([ymin, np.min(yy)])
117 | ymax = np.max([ymax, np.max(yy)])
118 |
119 | _annotation['area'] += self._compute_polygon_area(xx, yy)
120 |
121 | _annotation['bbox'] = [xmin, ymin, xmax-xmin, ymax-ymin]
122 |
123 | return _annotation
124 |
125 |
126 | def insert_annotation(self, annotation):
127 |
128 | # let's perform some checks...
129 | if 'image_id' not in annotation.keys():
130 | raise MissingImageIdException(f"Missing image ID = {annotation['image_id']}")
131 |
132 | if 'category_id' not in annotation.keys():
133 | raise MissingCategoryIdException(f"Missing category ID = {annotation['category_id']}")
134 |
135 | if 'id' not in annotation:
136 | annotation['id'] = len(self.annotations) + 1
137 |
138 | self.annotations.append(annotation)
139 |
140 | self._annotations_dict[annotation['id']] = annotation
141 |
142 | return annotation['id']
143 |
144 |
145 | def category(self, name: str, supercategory: str, id: int=None):
146 |
147 | _category = {
148 | "name": name,
149 | "supercategory": supercategory
150 | }
151 |
152 | if id != None:
153 | _category['id'] = id
154 |
155 | return _category
156 |
157 |
158 | def insert_category(self, category):
159 |
160 | if 'id' not in category:
161 | # coco expecting categories between (1, # categories)
162 | category['id'] = len(self.categories) + 1
163 |
164 | self.categories.append(category)
165 | self._categories_dict[category['id']] = category
166 |
167 | return category['id']
168 |
169 |
170 | def image(self,
171 | path: str,
172 | filename: str,
173 | year: int,
174 | license_id: int,
175 | id: int=None,
176 | date_captured: datetime=None,
177 | flickr_url: str=None,
178 | coco_url: str=None):
179 |
180 |
181 | full_filename = os.path.join(path, filename)
182 | img = Image.open(full_filename) # this was checked to be faster than skimage and rasterio
183 | width, height = img.size
184 |
185 | image = {
186 | "width": width,
187 | "height": height,
188 | "file_name": filename,
189 | "year": year,
190 | "license": license_id
191 | }
192 |
193 | if id != None:
194 | image['id'] = id
195 |
196 | if flickr_url != None:
197 | image['flickr_url'] = flickr_url
198 |
199 | if coco_url != None:
200 | image['coco_url'] = coco_url
201 |
202 | if date_captured != None:
203 | image['date_captured'] = date_captured
204 | else:
205 | dc = os.stat(full_filename).st_ctime
206 | image['date_captured'] = datetime.utcfromtimestamp(dc)
207 |
208 | return image
209 |
210 |
211 | def insert_image(self, image):
212 |
213 | # check whether the license_id is valid
214 | if image['license'] not in self._licenses_dict.keys():
215 | raise LicenseIdNotFoundException(f"License ID = {image['license']} not found.")
216 |
217 | if 'id' not in image:
218 | image['id'] = len(self.images)+1
219 |
220 | self.images.append(image)
221 | self._images_dict[image['id']] = image
222 |
223 | return image['id']
224 |
225 |
226 | def license(self, name: str, url: str, id: int=None):
227 |
228 | _license = {
229 | "name": name,
230 | "url": url
231 | }
232 |
233 | if id != None:
234 | _license['id'] = id
235 |
236 | return _license
237 |
238 |
239 | def insert_license(self, license):
240 |
241 | if 'id' not in license:
242 | license['id'] = len(self.licenses) + 1
243 |
244 | self.licenses.append(license)
245 | self._licenses_dict[license['id']] = license
246 |
247 | return license['id']
248 |
249 | def to_json(self):
250 |
251 | out = {}
252 | out['info'] = self.info
253 | out['images'] = self.images
254 | out['annotations'] = self.annotations
255 | out['licenses'] = self.licenses
256 | out['categories'] = self.categories
257 |
258 | return json.loads(json.dumps(out, default=self._default))
259 |
260 | # cf. https://stackoverflow.com/questions/24467972/calculate-area-of-polygon-given-x-y-coordinates
261 | def _compute_polygon_area(self, x, y):
262 | return 0.5*np.abs(np.dot(x,np.roll(y,1))-np.dot(y,np.roll(x,1)))
263 |
264 |
265 | def _default(self, o):
266 | if isinstance(o, (date, datetime)):
267 | return o.isoformat()
268 |
269 | def __str__(self):
270 |
271 | return json.dumps(self.to_json())
272 |
273 | def __repr__(self):
274 |
275 | return json.dumps(self.to_json())
276 |
277 |
278 | if __name__ == '__main__':
279 |
280 | from pprint import pprint
281 | coco = COCO()
282 | coco.set_info(2020, 'the version', 'the description', 'the contributor', 'the url')
283 |
284 |
285 | segmentation = [[214.59, 205.04, 218.39, 203.27,
286 | 218.39, 198.97, 221.18, 195.42,
287 | 225.73, 193.9, 228.77, 192.39,
288 | 241.17, 193.4, 243.45, 212.13,
289 | 252.57, 213.65, 252.06, 199.98,
290 | 256.87, 201.25, 260.92, 204.03,
291 | 263.45, 206.56, 267.75, 223.27,
292 | 259.91, 230.86, 249.78, 256.68,
293 | 253.58, 261.24, 243.39, 262.67,
294 | 241.78, 258.9, 236.94, 258.1,
295 | 237.21, 252.45, 239.9, 252.45,
296 | 240.17, 236.05, 237.48, 224.49,
297 | 233.17, 219.92, 225.11, 219.11,
298 | 219.73, 216.42, 214.62, 210.77,
299 | 213.81, 206.47, 215.43, 205.13],
300 | [247.96, 237.39, 246.89, 254.87, 248.77, 238.2, 248.77, 238.2]]
301 |
302 | license = coco.license('test license', 'test url')
303 | coco.insert_license(license)
304 |
305 | license = coco.license('test license', 'test url', 100)
306 | coco.insert_license(license)
307 |
308 | cat = coco.category('test cat', 'the supercat')
309 | coco.insert_category(cat)
310 |
311 | cat = coco.category('test cat', 'the supercat', 3)
312 | coco.insert_category(cat)
313 |
314 | try:
315 | ann = coco.annotation(image_id=1, category_id=1, segmentation=segmentation, iscrowd=0, annotation_id=0)
316 | coco.insert_annotation(ann)
317 | except Exception as e:
318 | print(f"Failed to insert annotation. Exception: {e}")
319 | sys.exit(1)
320 |
321 | ann = coco.annotation(image_id=1, category_id=1, segmentation=segmentation, iscrowd=0, annotation_id=123)
322 | coco.insert_annotation(ann)
323 |
324 | pprint(coco.to_json())
--------------------------------------------------------------------------------
/helpers/FOLDER.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import json
7 | import rasterio as rio
8 | from tqdm import tqdm
9 | from loguru import logger
10 |
11 | try:
12 | try:
13 | from helpers.misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, make_hard_link, BadFileExtensionException
14 | except ModuleNotFoundError:
15 | from misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, make_hard_link, BadFileExtensionException
16 | except Exception as e:
17 | logger.error(f"Could not import some dependencies. Exception: {e}")
18 | sys.exit(1)
19 |
20 |
21 | logger = format_logger(logger)
22 |
23 |
24 |
25 | def get_job_dict(tiles_gdf, base_path, end_path='all-images', year=None, save_metadata=False, overwrite=True):
26 | """Make a dictonnary of the necessary parameters to get the tiles from a base folder and place them in the right folder.
27 |
28 | Args:
29 | tiles_gdf (GeoDataFrame): tiles with the x, y, and z columns deduced from their id
30 | base_path (path): path to the original folder with the tiles
31 | end_path (path): path to the target folder used by the object detector. Defaults to 'all-images'.
32 | year (int, optional): year of the tile
33 | save_metadata (bool, optional): Whether to save the metadata in a json file. Defaults to False.
34 | overwrite (bool, optional): Whether to overwrite files already existing in the target folder or skip them. Defaults to True.
35 |
36 | Returns:
37 | dictionnary: parameters for the function 'get_image_to_folder' for each image file with the final image path as key.
38 | """
39 |
40 | job_dict = {}
41 |
42 | for tile in tqdm(tiles_gdf.itertuples(), total=len(tiles_gdf)):
43 |
44 | if year == 'multi-year':
45 | image_path = os.path.join(end_path, f'{tile.year_tile}_{tile.z}_{tile.x}_{tile.y}.tif')
46 | else:
47 | image_path = os.path.join(end_path, f'{tile.z}_{tile.x}_{tile.y}.tif')
48 | bbox = bounds_to_bbox(tile.geometry.bounds)
49 |
50 | job_dict[image_path] = {
51 | 'basepath': base_path,
52 | 'filename': image_path,
53 | 'bbox': bbox,
54 | 'year': tile.year_tile if 'year_tile' in tiles_gdf.keys() and str(year).isnumeric()==False else year,
55 | 'save_metadata': save_metadata,
56 | 'overwrite': overwrite
57 | }
58 |
59 | return job_dict
60 |
61 |
62 | def get_image_to_folder(basepath, filename, bbox, year, save_metadata=False, overwrite=True):
63 | """Copy the image from the original folder to the folder used by object detector.
64 |
65 | Args:
66 | basepath (path): path to the original image tile
67 | filename (path): path to the image tile for the object detector
68 | bbox (tuple): coordinates of the bounding box
69 | year (int): year of the image tile
70 | save_metadata (bool, optional): Whether to save the metadata in a json file. Defaults to False.
71 | overwrite (bool, optional): Whether to overwrite the files already existing in the target folder or to skip them. Defaults to True.
72 |
73 | Raises:
74 | BadFileExtensionException: The file must be GeoTIFF.
75 |
76 | Returns:
77 | dictionnary:
78 | - key: name of the geotiff file
79 | - value: image metadata
80 | """
81 |
82 | if not filename.endswith('.tif'):
83 | raise BadFileExtensionException("Filename must end with .tif")
84 |
85 | basefile = os.path.join(basepath, os.path.basename(filename))
86 | wld_filename = filename.replace('.tif', '_.wld') # world file
87 | md_filename = filename.replace('.tif', '.json')
88 | geotiff_filename = filename
89 |
90 | dont_overwrite_geotiff = (not overwrite) and os.path.isfile(geotiff_filename)
91 | if dont_overwrite_geotiff and ((save_metadata and os.path.isfile(md_filename)) or (not save_metadata)):
92 | return None
93 |
94 | xmin, ymin, xmax, ymax = [float(x) for x in bbox.split(',')]
95 |
96 | with rio.open(basefile) as src:
97 | image_meta = src.meta.copy()
98 | width = image_meta['width']
99 | height = image_meta['height']
100 | crs = image_meta['crs']
101 |
102 | make_hard_link(basefile, filename)
103 |
104 | # we can mimick ESRI MapImageLayer's metadata,
105 | # at least the section that we need
106 | image_metadata = {
107 | **({'year': year} if year else {}),
108 | "width": width,
109 | "height": height,
110 | "extent": {
111 | "xmin": xmin,
112 | "ymin": ymin,
113 | "xmax": xmax,
114 | "ymax": ymax,
115 | 'spatialReference': {
116 | 'latestWkid': str(crs.to_epsg())
117 | }
118 | }
119 | }
120 |
121 | wld = image_metadata_to_world_file(image_metadata)
122 |
123 | with open(wld_filename, 'w') as fp:
124 | fp.write(wld)
125 |
126 | if save_metadata:
127 | with open(md_filename, 'w') as fp:
128 | json.dump(image_metadata, fp)
129 |
130 | os.remove(wld_filename)
131 |
132 | return {geotiff_filename: image_metadata}
--------------------------------------------------------------------------------
/helpers/MIL.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import json
7 | import requests
8 |
9 | from osgeo import gdal
10 | from tqdm import tqdm
11 | from loguru import logger
12 |
13 | try:
14 | try:
15 | from helpers.misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
16 | except ModuleNotFoundError:
17 | from misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
18 | except Exception as e:
19 | logger.error(f"Could not import some dependencies. Exception: {e}")
20 | sys.exit(1)
21 |
22 |
23 | logger = format_logger(logger)
24 |
25 |
26 | def get_geotiff(mil_url, bbox, width, height, filename, image_sr="3857", bbox_sr="3857", save_metadata=False, overwrite=True):
27 | """
28 | by default, bbox must be in EPSG:3857
29 | """
30 |
31 | if not filename.endswith('.tif'):
32 | raise BadFileExtensionException("Filename must end with .tif")
33 |
34 | png_filename = filename.replace('.tif', '_.png')
35 | pgw_filename = filename.replace('.tif', '_.pgw')
36 | md_filename = filename.replace('.tif', '.json')
37 | geotiff_filename = f"{filename}"
38 |
39 | if save_metadata:
40 | if not overwrite and os.path.isfile(geotiff_filename) and os.path.isfile(geotiff_filename.replace('.tif', '.json')):
41 | return None
42 | else:
43 | if not overwrite and os.path.isfile(geotiff_filename):
44 | return None
45 |
46 | params = dict(
47 | bbox=bbox,
48 | format='png',
49 | size=f'{width},{height}',
50 | f='image',
51 | imageSR=image_sr,
52 | bboxSR=bbox_sr,
53 | transparent=False
54 | )
55 |
56 | xmin, ymin, xmax, ymax = [float(x) for x in bbox.split(',')]
57 |
58 | image_metadata = {
59 | "width": width,
60 | "height": height,
61 | "extent": {
62 | "xmin": xmin,
63 | "ymin": ymin,
64 | "xmax": xmax,
65 | "ymax": ymax,
66 | 'spatialReference': {
67 | 'latestWkid': bbox_sr
68 | }
69 | }
70 | }
71 |
72 | r = requests.post(mil_url + '/export', data=params, timeout=30)
73 |
74 | if r.status_code == 200:
75 |
76 | with open(png_filename, 'wb') as fp:
77 | fp.write(r.content)
78 |
79 | pgw = image_metadata_to_world_file(image_metadata)
80 |
81 | with open(pgw_filename, 'w') as fp:
82 | fp.write(pgw)
83 |
84 | if save_metadata:
85 | with open(md_filename, 'w') as fp:
86 | json.dump(image_metadata, fp)
87 |
88 | try:
89 | src_ds = gdal.Open(png_filename)
90 | gdal.Translate(geotiff_filename, src_ds, options=f'-of GTiff -a_srs EPSG:{image_sr}')
91 | src_ds = None
92 | except Exception as e:
93 | logger.warning(f"Exception in the 'get_geotiff' function: {e}")
94 |
95 | os.remove(png_filename)
96 | os.remove(pgw_filename)
97 |
98 | return {geotiff_filename: image_metadata}
99 |
100 | else:
101 |
102 | return {}
103 |
104 |
105 | def get_job_dict(tiles_gdf, mil_url, width, height, img_path, image_sr, save_metadata=False, overwrite=True):
106 |
107 | job_dict = {}
108 |
109 | for tile in tqdm(tiles_gdf.itertuples(), total=len(tiles_gdf)):
110 |
111 | img_filename = os.path.join(img_path, f'{tile.z}_{tile.x}_{tile.y}.tif')
112 | bbox = bounds_to_bbox(tile.geometry.bounds)
113 |
114 | job_dict[img_filename] = {
115 | 'mil_url': mil_url,
116 | 'bbox': bbox,
117 | 'width': width,
118 | 'height': height,
119 | 'filename': img_filename,
120 | 'image_sr': image_sr,
121 | 'bbox_sr': tiles_gdf.crs.to_epsg(),
122 | 'save_metadata': save_metadata,
123 | 'overwrite': overwrite
124 | }
125 |
126 | return job_dict
127 |
128 |
129 | if __name__ == '__main__':
130 |
131 | print('Doing nothing.')
--------------------------------------------------------------------------------
/helpers/WMS.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import json
7 | import requests
8 |
9 | from osgeo import gdal
10 | from tqdm import tqdm
11 | from loguru import logger
12 |
13 | try:
14 | try:
15 | from helpers.misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
16 | except ModuleNotFoundError:
17 | from misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
18 | except Exception as e:
19 | logger.error(f"Could not import some dependencies. Exception: {e}")
20 | sys.exit(1)
21 |
22 |
23 | logger = format_logger(logger)
24 |
25 |
26 | def get_geotiff(wms_url, layers, bbox, width, height, filename, srs="EPSG:3857", save_metadata=False, overwrite=True):
27 | """
28 | ...
29 | """
30 |
31 | if not filename.endswith('.tif'):
32 | raise BadFileExtensionException("Filename must end with .tif")
33 |
34 | png_filename = filename.replace('.tif', '_.png')
35 | pgw_filename = filename.replace('.tif', '_.pgw')
36 | md_filename = filename.replace('.tif', '.json')
37 | geotiff_filename = filename
38 |
39 | if save_metadata:
40 | if not overwrite and os.path.isfile(geotiff_filename) and os.path.isfile(geotiff_filename.replace('.tif', '.json')):
41 | return None
42 | else:
43 | if not overwrite and os.path.isfile(geotiff_filename):
44 | return None
45 |
46 | params = dict(
47 | service="WMS",
48 | version="1.1.1",
49 | request="GetMap",
50 | layers=layers,
51 | format="image/png",
52 | srs=srs,
53 | transparent=True,
54 | styles="",
55 | bbox=bbox,
56 | width=width,
57 | height=height
58 | )
59 |
60 | xmin, ymin, xmax, ymax = [float(x) for x in bbox.split(',')]
61 |
62 | # we can mimick ESRI MapImageLayer's metadata,
63 | # at least the section that we need
64 | image_metadata = {
65 | "width": width,
66 | "height": height,
67 | "extent": {
68 | "xmin": xmin,
69 | "ymin": ymin,
70 | "xmax": xmax,
71 | "ymax": ymax,
72 | 'spatialReference': {
73 | 'latestWkid': srs.split(':')[1]
74 | }
75 | }
76 | }
77 |
78 | r = requests.get(wms_url, params=params, allow_redirects=True)
79 |
80 | if r.status_code == 200:
81 |
82 | with open(png_filename, 'wb') as fp:
83 | fp.write(r.content)
84 |
85 | pgw = image_metadata_to_world_file(image_metadata)
86 |
87 | with open(pgw_filename, 'w') as fp:
88 | fp.write(pgw)
89 |
90 | if save_metadata:
91 | with open(md_filename, 'w') as fp:
92 | json.dump(image_metadata, fp)
93 |
94 | try:
95 | src_ds = gdal.Open(png_filename)
96 | gdal.Translate(geotiff_filename, src_ds, options=f'-of GTiff -a_srs {srs}')
97 | src_ds = None
98 | except Exception as e:
99 | logger.warning(f"Exception in the 'get_geotiff' function: {e}")
100 |
101 | os.remove(png_filename)
102 | os.remove(pgw_filename)
103 |
104 | return {geotiff_filename: image_metadata}
105 |
106 | else:
107 | logger.warning(f"Failed to get image from WMS: HTTP Status Code = {r.status_code}, received text = '{r.text}'")
108 | return {}
109 |
110 |
111 | def get_job_dict(tiles_gdf, wms_url, layers, width, height, img_path, srs, save_metadata=False, overwrite=True):
112 |
113 | job_dict = {}
114 |
115 | for tile in tqdm(tiles_gdf.itertuples(), total=len(tiles_gdf)):
116 |
117 | img_filename = os.path.join(img_path, f'{tile.z}_{tile.x}_{tile.y}.tif')
118 | bbox = bounds_to_bbox(tile.geometry.bounds)
119 |
120 | job_dict[img_filename] = {
121 | 'wms_url': wms_url,
122 | 'layers': layers,
123 | 'bbox': bbox,
124 | 'width': width,
125 | 'height': height,
126 | 'filename': img_filename,
127 | 'srs': srs,
128 | 'save_metadata': save_metadata,
129 | 'overwrite': overwrite
130 | }
131 |
132 | return job_dict
133 |
134 |
135 | if __name__ == '__main__':
136 |
137 | print("Testing using Neuchâtel Canton's WMS...")
138 |
139 | ROOT_URL = "https://sitn.ne.ch/mapproxy95/service"
140 | BBOX = "763453.0385123404,5969120.412845984,763605.9125689107,5969273.286902554"
141 | WIDTH=256
142 | HEIGHT=256
143 | LAYERS = "ortho2019"
144 | SRS="EPSG:900913"
145 | OUTPUT_IMG = 'test.tif'
146 | OUTPUT_DIR = 'test_output'
147 | # let's make the output directory in case it doesn't exist
148 | if not os.path.exists(OUTPUT_DIR):
149 | os.makedirs(OUTPUT_DIR)
150 |
151 | BBOX = "763453.0385123404,5969120.412845984,763605.9125689107,5969273.286902554"
152 |
153 | out_filename = os.path.join(OUTPUT_DIR, OUTPUT_IMG)
154 |
155 | outcome = get_geotiff(
156 | ROOT_URL,
157 | LAYERS,
158 | bbox=BBOX,
159 | width=WIDTH,
160 | height=HEIGHT,
161 | filename=out_filename,
162 | srs=SRS,
163 | save_metadata=True
164 | )
165 |
166 | if outcome != {}:
167 | print(f'...done. An image was generated: {out_filename}')
--------------------------------------------------------------------------------
/helpers/XYZ.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import sys
6 | import json
7 | import requests
8 |
9 | from osgeo import gdal
10 | from tqdm import tqdm
11 | from loguru import logger
12 |
13 | try:
14 | try:
15 | from helpers.misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
16 | except ModuleNotFoundError:
17 | from misc import image_metadata_to_world_file, bounds_to_bbox, format_logger, BadFileExtensionException
18 | except Exception as e:
19 | logger.error(f"Could not import some dependencies. Exception: {e}")
20 | sys.exit(1)
21 |
22 |
23 | logger = format_logger(logger)
24 |
25 |
26 | class UnsupportedImageFormatException(Exception):
27 | "Raised when the detected image format is not supported"
28 | pass
29 |
30 |
31 | def detect_img_format(url):
32 |
33 | lower_url = url.lower()
34 |
35 | if '.png' in lower_url:
36 | return 'png'
37 | elif any(x in lower_url for x in ['.jpg', '.jpeg']):
38 | return 'jpg'
39 | elif any(x in lower_url for x in ['.tif', '.tiff']):
40 | return 'tif'
41 | else:
42 | return None
43 |
44 |
45 | def get_geotiff(xyz_url, bbox, year, xyz, filename, save_metadata=False, overwrite=True):
46 | """ Download tile url formatting and addition of image metadata
47 |
48 | Args:
49 | xyz_url (path): path to the original image tile
50 | bbox (tuple): coordinates of the bounding box
51 | year (int): year of the image tile
52 | xyz (tuple): x, y, z coordinates of the tile
53 | save_metadata (bool, optional): Whether to save the metadata in a json file. Defaults to False.
54 | overwrite (bool, optional): Whether to overwrite the files already existing in the target folder or to skip them. Defaults to True.
55 |
56 | Returns:
57 | dictionnary:
58 | - key: name of the geotiff file
59 | - value: image metadata
60 | """
61 |
62 | if not filename.endswith('.tif'):
63 | raise BadFileExtensionException("Filename must end with .tif")
64 |
65 | img_format = detect_img_format(xyz_url)
66 |
67 | if not img_format:
68 | raise UnsupportedImageFormatException("Unsupported image format")
69 |
70 | img_filename = filename.replace('.tif', f'_.{img_format}')
71 | wld_filename = filename.replace('.tif', '_.wld') # world file
72 | md_filename = filename.replace('.tif', '.json')
73 | geotiff_filename = filename
74 |
75 | if save_metadata:
76 | if not overwrite and os.path.isfile(geotiff_filename) and os.path.isfile(md_filename):
77 | return None
78 | else:
79 | if not overwrite and os.path.isfile(geotiff_filename):
80 | return None
81 |
82 | x, y, z = xyz
83 |
84 | xyz_url_completed = xyz_url.replace('{year}', str(year)).replace('{z}', str(z)).replace('{x}', str(x)).replace('{y}', str(y))
85 |
86 | xmin, ymin, xmax, ymax = [float(x) for x in bbox.split(',')]
87 |
88 | r = requests.get(xyz_url_completed, allow_redirects=True)
89 |
90 | if r.status_code == 200:
91 |
92 | with open(img_filename, 'wb') as fp:
93 | fp.write(r.content)
94 |
95 | src_ds = gdal.Open(img_filename)
96 | width, height = src_ds.RasterXSize, src_ds.RasterYSize
97 | src_ds = None
98 |
99 | # we can mimick ESRI MapImageLayer's metadata,
100 | # at least the section that we need
101 | image_metadata = {
102 | **({'year': year} if year else {}),
103 | "width": width,
104 | "height": height,
105 | "extent": {
106 | "xmin": xmin,
107 | "ymin": ymin,
108 | "xmax": xmax,
109 | "ymax": ymax,
110 | 'spatialReference': {
111 | 'latestWkid': "3857" # <- NOTE: hard-coded
112 | }
113 | }
114 | }
115 |
116 | wld = image_metadata_to_world_file(image_metadata)
117 |
118 | with open(wld_filename, 'w') as fp:
119 | fp.write(wld)
120 |
121 | if save_metadata:
122 | with open(md_filename, 'w') as fp:
123 | json.dump(image_metadata, fp)
124 |
125 | try:
126 | src_ds = gdal.Open(img_filename)
127 | # NOTE: EPSG:3857 is hard-coded
128 | gdal.Translate(geotiff_filename, src_ds, options='-of GTiff -a_srs EPSG:3857')
129 | src_ds = None
130 | except Exception as e:
131 | logger.warning(f"Exception in the 'get_geotiff' function: {e}")
132 |
133 | os.remove(img_filename)
134 | os.remove(wld_filename)
135 |
136 | return {geotiff_filename: image_metadata}
137 |
138 | else:
139 |
140 | return {}
141 |
142 |
143 | def get_job_dict(tiles_gdf, xyz_url, img_path, year='None', save_metadata=False, overwrite=True):
144 |
145 | job_dict = {}
146 |
147 | for tile in tqdm(tiles_gdf.itertuples(), total=len(tiles_gdf)):
148 |
149 | if year == 'multi-year':
150 | img_filename = os.path.join(img_path, f'{tile.year_tile}_{tile.z}_{tile.x}_{tile.y}.tif')
151 | else:
152 | img_filename = os.path.join(img_path, f'{tile.z}_{tile.x}_{tile.y}.tif')
153 |
154 | bbox = bounds_to_bbox(tile.geometry.bounds)
155 |
156 | job_dict[img_filename] = {
157 | 'xyz_url': xyz_url,
158 | 'bbox': bbox,
159 | 'year': tile.year_tile if 'year_tile' in tiles_gdf.keys() and str(year).isnumeric()==False else year,
160 | 'xyz': (tile.x, tile.y, tile.z),
161 | 'filename': img_filename,
162 | 'save_metadata': save_metadata,
163 | 'overwrite': overwrite
164 | }
165 |
166 | return job_dict
167 |
168 |
169 | if __name__ == '__main__':
170 |
171 | print("Testing using TiTiler's XYZ...")
172 |
173 | QUERY_STR = "url=/data/mosaic.json&bidx=2&bidx=3&bidx=4&bidx=1&no_data=0&return_mask=false&pixel_selection=lowest"
174 |
175 | #ROOT_URL = f"https://titiler.vm-gpu-01.stdl.ch/mosaicjson/tiles/{{z}}/{{x}}/{{y}}.jpg?{QUERY_STR}"
176 | ROOT_URL = f"https://titiler.vm-gpu-01.stdl.ch/mosaicjson/tiles/{{z}}/{{x}}/{{y}}.png?{QUERY_STR}"
177 | ROOT_URL = f"https://titiler.vm-gpu-01.stdl.ch/mosaicjson/tiles/{{z}}/{{x}}/{{y}}.tif?{QUERY_STR}"
178 | BBOX = "860986.68660422,5925092.68455372,861139.56066079,5925245.55861029"
179 | xyz= (136704, 92313, 18)
180 | OUTPUT_IMG = 'test.tif'
181 | OUTPUT_DIR = 'test_output'
182 | # let's make the output directory in case it doesn't exist
183 | if not os.path.exists(OUTPUT_DIR):
184 | os.makedirs(OUTPUT_DIR)
185 |
186 | out_filename = os.path.join(OUTPUT_DIR, OUTPUT_IMG)
187 |
188 | outcome = get_geotiff(
189 | ROOT_URL,
190 | bbox=BBOX,
191 | xyz=xyz,
192 | filename=out_filename,
193 | save_metadata=True
194 | )
195 |
196 | if outcome != {}:
197 | print(f'...done. An image was generated: {out_filename}')
198 | else:
199 | print("An error occurred.")
--------------------------------------------------------------------------------
/helpers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/helpers/__init__.py
--------------------------------------------------------------------------------
/helpers/constants.py:
--------------------------------------------------------------------------------
1 | DONE_MSG = "...done."
2 | SCATTER_PLOT_MODE = 'markers+lines'
--------------------------------------------------------------------------------
/helpers/detectron2.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 |
4 | import os
5 | import time
6 | import torch
7 | import numpy as np
8 | import logging
9 |
10 | import datetime
11 |
12 | from detectron2.engine.hooks import HookBase
13 | from detectron2.engine import DefaultTrainer
14 | from detectron2.data import build_detection_test_loader, DatasetMapper
15 | from detectron2.evaluation import COCOEvaluator
16 | from detectron2.utils import comm
17 | from detectron2.utils.logger import log_every_n_seconds
18 |
19 | from rasterio import features
20 | from shapely.affinity import affine_transform
21 | from shapely.geometry import box
22 | from rdp import rdp
23 |
24 | # cf. https://medium.com/@apofeniaco/training-on-detectron2-with-a-validation-set-and-plot-loss-on-it-to-avoid-overfitting-6449418fbf4e
25 | # cf. https://towardsdatascience.com/face-detection-on-custom-dataset-with-detectron2-and-pytorch-using-python-23c17e99e162
26 | # cf. http://cocodataset.org/#detection-eval
27 | class LossEvalHook(HookBase):
28 | def __init__(self, eval_period, model, data_loader):
29 | self._model = model
30 | self._period = eval_period
31 | self._data_loader = data_loader
32 |
33 | def _do_loss_eval(self):
34 |
35 | # Copying inference_on_dataset from evaluator.py
36 | total = len(self._data_loader)
37 | num_warmup = min(5, total - 1)
38 |
39 | start_time = time.perf_counter()
40 | total_compute_time = 0
41 | losses = []
42 | for idx, inputs in enumerate(self._data_loader):
43 | if idx == num_warmup:
44 | start_time = time.perf_counter()
45 | total_compute_time = 0
46 | start_compute_time = time.perf_counter()
47 | if torch.cuda.is_available():
48 | torch.cuda.synchronize()
49 | total_compute_time += time.perf_counter() - start_compute_time
50 | iters_after_start = idx + 1 - num_warmup * int(idx >= num_warmup)
51 | seconds_per_img = total_compute_time / iters_after_start
52 | if idx >= num_warmup * 2 or seconds_per_img > 5:
53 | total_seconds_per_img = (time.perf_counter() - start_time) / iters_after_start
54 | eta = datetime.timedelta(seconds=int(total_seconds_per_img * (total - idx - 1)))
55 | log_every_n_seconds(
56 | logging.INFO,
57 | "Loss on Validation done {}/{}. {:.4f} s / img. ETA={}".format(
58 | idx + 1, total, seconds_per_img, str(eta)
59 | ),
60 | n=5,
61 | )
62 | loss_batch = self._get_loss(inputs)
63 | losses.append(loss_batch)
64 | mean_loss = np.mean(losses)
65 | self.trainer.storage.put_scalar('validation_loss', mean_loss)
66 | comm.synchronize()
67 |
68 | return losses
69 |
70 | def _get_loss(self, data):
71 |
72 | # How loss is calculated on train_loop
73 | metrics_dict = self._model(data)
74 | metrics_dict = {
75 | k: v.detach().cpu().item() if isinstance(v, torch.Tensor) else float(v)
76 | for k, v in metrics_dict.items()
77 | }
78 | total_losses_reduced = sum(loss for loss in metrics_dict.values())
79 | return total_losses_reduced
80 |
81 |
82 | def after_step(self):
83 |
84 | next_iter = self.trainer.iter + 1
85 | is_final = next_iter == self.trainer.max_iter
86 | if is_final or (self._period > 0 and next_iter % self._period == 0):
87 | self._do_loss_eval()
88 | self.trainer.storage.put_scalars(timetest=12)
89 |
90 |
91 |
92 | class CocoTrainer(DefaultTrainer):
93 |
94 | # https://github.com/facebookresearch/detectron2/blob/main/tools/train_net.py#L91
95 | @classmethod
96 | def build_evaluator(cls, cfg, dataset_name, output_folder=None):
97 |
98 | if output_folder is None:
99 | output_folder = os.path.join(cfg.OUTPUT_DIR, "inference")
100 |
101 | os.makedirs("COCO_eval", exist_ok=True)
102 |
103 | return COCOEvaluator(dataset_name, None, False, output_folder)
104 |
105 |
106 | def build_hooks(self):
107 |
108 | hooks = super().build_hooks()
109 |
110 | hooks.insert(-1,
111 | LossEvalHook(
112 | self.cfg.TEST.EVAL_PERIOD,
113 | self.model,
114 | build_detection_test_loader(self.cfg, self.cfg.DATASETS.TEST[0], DatasetMapper(self.cfg, True))
115 | )
116 | )
117 |
118 | return hooks
119 |
120 |
121 |
122 | # HELPER FUNCTIONS
123 |
124 | def _preprocess(dets):
125 |
126 | fields = dets['instances'].get_fields()
127 |
128 | out = {}
129 |
130 | # pred_boxes
131 | if 'pred_boxes' in fields.keys():
132 | out['pred_boxes'] = [box.cpu().numpy() for box in fields['pred_boxes']]
133 | # det_classes
134 | if 'pred_classes' in fields.keys():
135 | out['pred_classes'] = fields['pred_classes'].cpu().numpy()
136 | # pred_masks
137 | if 'pred_masks' in fields.keys():
138 | out['pred_masks'] = fields['pred_masks'].cpu().numpy()
139 | # scores
140 | if 'scores' in fields.keys():
141 | out['scores'] = fields['scores'].cpu().numpy()
142 |
143 | return out
144 |
145 |
146 | def detectron2dets_to_features(dets, im_path, transform, rdp_enabled, rdp_eps, year="None"):
147 |
148 | feats = []
149 |
150 | tmp = _preprocess(dets)
151 |
152 | for idx in range(len(tmp['scores'])):
153 |
154 | instance = {}
155 | instance['score'] = tmp['scores'][idx]
156 | instance['pred_class'] = tmp['pred_classes'][idx]
157 |
158 | if 'pred_masks' in tmp.keys():
159 |
160 | pred_mask_int = tmp['pred_masks'][idx].astype(np.uint8)
161 | _feats = [
162 | {
163 | 'type': 'Feature',
164 | 'properties': {'score': instance['score'], 'det_class': instance['pred_class'], 'image': os.path.basename(im_path), 'year_det': year},
165 | 'geometry': geom
166 | } for (geom, v) in features.shapes(pred_mask_int, mask=None, transform=transform) if v == 1.0
167 | ]
168 |
169 | for f in _feats:
170 | if rdp_enabled:
171 | coords = f['geometry']['coordinates']
172 | coords_after_rdp = [rdp(x, epsilon=rdp_eps) for x in coords]
173 | f['geometry']['coordinates'] = coords_after_rdp
174 |
175 | feats.append(f)
176 |
177 | else: # if pred_masks does not exist, pred_boxes should (it depends on Detectron2's MASK_ON config param)
178 | instance['pred_box'] = tmp['pred_boxes'][idx]
179 |
180 | geom = affine_transform(box(*instance['pred_box']), [transform.a, transform.b, transform.d, transform.e, transform.xoff, transform.yoff])
181 | _feats = [
182 | {
183 | 'type': 'Feature',
184 | 'properties': {'score': instance['score'], 'det_class': instance['pred_class'], 'image': os.path.basename(im_path), 'year_det': year},
185 | 'geometry': geom
186 | }
187 | ]
188 |
189 | feats += _feats
190 |
191 | return feats
--------------------------------------------------------------------------------
/helpers/metrics.py:
--------------------------------------------------------------------------------
1 | import geopandas as gpd
2 | import pandas as pd
3 |
4 |
5 |
6 | def get_fractional_sets(dets_gdf, labels_gdf, iou_threshold=0.25, area_threshold=None):
7 | """
8 | Find the intersecting detections and labels.
9 | Control their IoU and class to get the TP.
10 | Tag detections and labels not intersecting or not intersecting enough as FP and FN respectively.
11 | Save the intersections with mismatched class ids in a separate geodataframe.
12 |
13 | Args:
14 | dets_gdf (geodataframe): geodataframe of the detections.
15 | labels_gdf (geodataframe): geodataframe of the labels.
16 | iou_threshold (float): threshold to apply on the IoU to determine if detections and labels can be matched. Defaults to 0.25.
17 | area_threshold (float): threshold applied on clipped label and detection polygons to discard the smallest ones. Default None
18 | Raises:
19 | Exception: CRS mismatch
20 |
21 | Returns:
22 | tuple:
23 | - geodataframe: true positive intersections between a detection and a label;
24 | - geodataframe: false postive detections;
25 | - geodataframe: false negative labels;
26 | - geodataframe: intersections between a detection and a label with a mismatched class id.
27 | - geodataframe: label and detection polygons with an area smaller than the threshold.
28 | """
29 |
30 | _dets_gdf = dets_gdf.reset_index(drop=True)
31 | _labels_gdf = labels_gdf.reset_index(drop=True)
32 |
33 | small_poly_gdf = gpd.GeoDataFrame()
34 |
35 | if len(_labels_gdf) == 0:
36 | columns_list = ['area', 'geometry', 'dataset', 'label_id', 'label_class']
37 | fp_gdf = _dets_gdf.copy()
38 | tp_gdf = gpd.GeoDataFrame(columns=columns_list + ['det_id', 'det_class', 'score', 'IOU'])
39 | fn_gdf = gpd.GeoDataFrame(columns=columns_list)
40 | mismatched_classes_gdf = gpd.GeoDataFrame(columns=columns_list + ['det_id', 'det_class', 'score', 'IOU'])
41 | return tp_gdf, fp_gdf, fn_gdf, mismatched_classes_gdf, small_poly_gdf
42 |
43 | assert(_dets_gdf.crs == _labels_gdf.crs), f"CRS Mismatch: detections' CRS = {_dets_gdf.crs}, labels' CRS = {_labels_gdf.crs}"
44 |
45 | # we add a id column to the labels dataset, which should not exist in detections too;
46 | # this allows us to distinguish matching from non-matching detections
47 | _labels_gdf['label_id'] = _labels_gdf.index
48 | _dets_gdf['det_id'] = _dets_gdf.index
49 | # We need to keep both geometries after sjoin to check the best intersection over union
50 | _labels_gdf['label_geom'] = _labels_gdf.geometry
51 |
52 | # Filter detections and labels with area less than thd value
53 | if area_threshold:
54 | _dets_gdf['area'] = _dets_gdf.area
55 | filter_dets_gdf = _dets_gdf[_dets_gdf['area']=area_threshold]
57 | _labels_gdf['area'] = _labels_gdf.area
58 | filter_labels_gdf = _labels_gdf[_labels_gdf['area']=area_threshold]
60 | small_poly_gdf = pd.concat([filter_dets_gdf, filter_labels_gdf])
61 |
62 | # TRUE POSITIVES
63 | left_join = gpd.sjoin(_dets_gdf, _labels_gdf, how='left', predicate='intersects', lsuffix='left', rsuffix='right')
64 |
65 | # Test that something is detected
66 | candidates_tp_gdf = left_join[left_join.label_id.notnull()].copy()
67 |
68 | # IoU computation between labels and detections
69 | geom1 = candidates_tp_gdf['geometry'].to_numpy().tolist()
70 | geom2 = candidates_tp_gdf['label_geom'].to_numpy().tolist()
71 | candidates_tp_gdf.loc[:, ['IOU']] = [intersection_over_union(i, ii) for (i, ii) in zip(geom1, geom2)]
72 |
73 | # Filter detections based on IoU value
74 | best_matches_gdf = candidates_tp_gdf.groupby(['det_id'], group_keys=False).apply(lambda g:g[g.IOU==g.IOU.max()])
75 | best_matches_gdf.drop_duplicates(subset=['det_id'], inplace=True) # <- could change the results depending on which line is dropped (but rarely effective)
76 |
77 | # Detection, resp labels, with IOU lower than threshold value are considered as FP, resp FN, and saved as such
78 | actual_matches_gdf = best_matches_gdf[best_matches_gdf['IOU'] >= iou_threshold].copy()
79 | actual_matches_gdf = actual_matches_gdf.sort_values(by=['IOU'], ascending=False).drop_duplicates(subset=['label_id', 'tile_id'])
80 | actual_matches_gdf['IOU'] = actual_matches_gdf.IOU.round(3)
81 |
82 | matched_det_ids = actual_matches_gdf['det_id'].unique().tolist()
83 | matched_label_ids = actual_matches_gdf['label_id'].unique().tolist()
84 | fp_gdf_temp = candidates_tp_gdf[~candidates_tp_gdf.det_id.isin(matched_det_ids)].drop_duplicates(subset=['det_id'], ignore_index=True)
85 | fn_gdf_temp = candidates_tp_gdf[~candidates_tp_gdf.label_id.isin(matched_label_ids)].drop_duplicates(subset=['label_id'], ignore_index=True)
86 | fn_gdf_temp.loc[:, 'geometry'] = fn_gdf_temp.label_geom
87 |
88 | # Test that labels and detections share the same class (id starting at 1 for labels and at 0 for detections)
89 | condition = actual_matches_gdf.label_class == actual_matches_gdf.det_class + 1
90 | tp_gdf = actual_matches_gdf[condition].reset_index(drop=True)
91 |
92 | mismatched_classes_gdf = actual_matches_gdf[~condition].reset_index(drop=True)
93 | mismatched_classes_gdf.drop(columns=['x', 'y', 'z', 'dataset_right', 'label_geom'], errors='ignore', inplace=True)
94 | mismatched_classes_gdf.rename(columns={'dataset_left': 'dataset'}, inplace=True)
95 |
96 | # FALSE POSITIVES
97 | fp_gdf = left_join[left_join.label_id.isna()]
98 | assert(len(fp_gdf[fp_gdf.duplicated()]) == 0)
99 | fp_gdf = pd.concat([fp_gdf_temp, fp_gdf], ignore_index=True)
100 | fp_gdf.drop(
101 | columns=_labels_gdf.drop(columns='geometry').columns.to_list() + ['index_right', 'dataset_right', 'label_geom', 'IOU'],
102 | errors='ignore',
103 | inplace=True
104 | )
105 | fp_gdf.rename(columns={'dataset_left': 'dataset'}, inplace=True)
106 |
107 | # FALSE NEGATIVES
108 | right_join = gpd.sjoin(_dets_gdf, _labels_gdf, how='right', predicate='intersects', lsuffix='dets', rsuffix='labels')
109 | fn_gdf = right_join[right_join.score.isna()].copy()
110 | fn_gdf.drop_duplicates(subset=['label_id', 'tile_id'], inplace=True)
111 | fn_gdf = pd.concat([fn_gdf_temp, fn_gdf], ignore_index=True)
112 | fn_gdf.drop(
113 | columns=_dets_gdf.drop(columns='geometry').columns.to_list() + ['dataset_dets', 'index_labels', 'x', 'y', 'z', 'label_geom', 'IOU', 'index_dets'],
114 | errors='ignore',
115 | inplace=True
116 | )
117 | fn_gdf.rename(columns={'dataset_dets': 'dataset'}, inplace=True)
118 |
119 |
120 | return tp_gdf, fp_gdf, fn_gdf, mismatched_classes_gdf, small_poly_gdf
121 |
122 |
123 | def get_metrics(tp_gdf, fp_gdf, fn_gdf, mismatch_gdf, id_classes=0, method='macro-average'):
124 | """Determine the metrics based on the TP, FP and FN
125 |
126 | Args:
127 | tp_gdf (geodataframe): true positive detections
128 | fp_gdf (geodataframe): false positive detections
129 | fn_gdf (geodataframe): false negative labels
130 | mismatch_gdf (geodataframe): labels and detections intersecting with a mismatched class id
131 | id_classes (list): list of the possible class ids. Defaults to 0.
132 | method (str): method used to compute multi-class metrics. Default to macro-average
133 |
134 | Returns:
135 | tuple:
136 | - dict: TP count for each class
137 | - dict: FP count for each class
138 | - dict: FN count for each class
139 | - dict: precision for each class
140 | - dict: recall for each class
141 | - dict: f1-score for each class
142 | - float: accuracy
143 | - float: precision;
144 | - float: recall;
145 | - float: f1 score.
146 | """
147 |
148 | by_class_dict = {key: 0 for key in id_classes}
149 | tp_k = by_class_dict.copy()
150 | fp_k = by_class_dict.copy()
151 | fn_k = by_class_dict.copy()
152 | p_k = by_class_dict.copy()
153 | r_k = by_class_dict.copy()
154 | count_k = by_class_dict.copy()
155 | pw_k = by_class_dict.copy()
156 | rw_k = by_class_dict.copy()
157 |
158 | total_labels = len(tp_gdf) + len(fn_gdf) + len(mismatch_gdf)
159 |
160 | for id_cl in id_classes:
161 |
162 | pure_fp_count = len(fp_gdf[fp_gdf.det_class==id_cl])
163 | pure_fn_count = len(fn_gdf[fn_gdf.label_class==id_cl+1]) # label class starting at 1 and id class at 0
164 |
165 | mismatched_fp_count = len(mismatch_gdf[mismatch_gdf.det_class==id_cl])
166 | mismatched_fn_count = len(mismatch_gdf[mismatch_gdf.label_class==id_cl+1])
167 |
168 | fp_count = pure_fp_count + mismatched_fp_count
169 | fn_count = pure_fn_count + mismatched_fn_count
170 | tp_count = len(tp_gdf[tp_gdf.det_class==id_cl])
171 |
172 | fp_k[id_cl] = fp_count
173 | fn_k[id_cl] = fn_count
174 | tp_k[id_cl] = tp_count
175 |
176 | count_k[id_cl] = tp_count + fn_count
177 | if tp_count > 0:
178 | p_k[id_cl] = tp_count / (tp_count + fp_count)
179 | r_k[id_cl] = tp_count / (tp_count + fn_count)
180 |
181 | if (method == 'macro-weighted-average') & (total_labels > 0):
182 | pw_k[id_cl] = (count_k[id_cl] / total_labels) * p_k[id_cl]
183 | rw_k[id_cl] = (count_k[id_cl] / total_labels) * r_k[id_cl]
184 |
185 | if method == 'macro-average':
186 | precision = sum(p_k.values()) / len(id_classes)
187 | recall = sum(r_k.values()) / len(id_classes)
188 | elif method == 'macro-weighted-average':
189 | precision = sum(pw_k.values()) / len(id_classes)
190 | recall = sum(rw_k.values()) / len(id_classes)
191 | elif method == 'micro-average':
192 | if sum(tp_k.values()) == 0:
193 | precision = 0
194 | recall = 0
195 | else:
196 | precision = sum(tp_k.values()) / (sum(tp_k.values()) + sum(fp_k.values()))
197 | recall = sum(tp_k.values()) / (sum(tp_k.values()) + sum(fn_k.values()))
198 |
199 | if precision==0 and recall==0:
200 | return tp_k, fp_k, fn_k, p_k, r_k, 0, 0, 0
201 |
202 | f1 = 2 * precision * recall / (precision + recall)
203 |
204 | return tp_k, fp_k, fn_k, p_k, r_k, precision, recall, f1
205 |
206 |
207 | def intersection_over_union(polygon1_shape, polygon2_shape):
208 | """Determine the intersection area over union area (IOU) of two polygons
209 |
210 | Args:
211 | polygon1_shape (geometry): first polygon
212 | polygon2_shape (geometry): second polygon
213 |
214 | Returns:
215 | int: Unrounded ratio between the intersection and union area
216 | """
217 |
218 | # Calculate intersection and union, and the IOU
219 | polygon_intersection = polygon1_shape.intersection(polygon2_shape).area
220 | polygon_union = polygon1_shape.area + polygon2_shape.area - polygon_intersection
221 |
222 | if polygon_union != 0:
223 | iou = polygon_intersection / polygon_union
224 | else:
225 | iou = 0
226 |
227 | return iou
--------------------------------------------------------------------------------
/helpers/split_tiles.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | # -*- coding: utf-8 -*-
3 |
4 | import warnings
5 | warnings.simplefilter(action='ignore', category=FutureWarning)
6 |
7 | import os
8 | import sys
9 | import geopandas as gpd
10 | import pandas as pd
11 |
12 | from tqdm import tqdm
13 |
14 | # the following lines allow us to import modules from within this file's parent folder
15 | from inspect import getsourcefile
16 | current_path = os.path.abspath(getsourcefile(lambda:0))
17 | current_dir = os.path.dirname(current_path)
18 | parent_dir = current_dir[:current_dir.rfind(os.path.sep)]
19 | sys.path.insert(0, parent_dir)
20 |
21 | from helpers import misc
22 | from helpers.constants import DONE_MSG
23 |
24 | from loguru import logger
25 | logger = misc.format_logger(logger)
26 |
27 |
28 | def split_additional_tiles(tiles_gdf, gt_tiles_gdf, trn_tiles_ids, val_tiles_ids, tst_tiles_ids, tile_type, frac_trn, seed):
29 | _tiles_gdf = tiles_gdf.copy()
30 | _gt_tiles_gdf = gt_tiles_gdf.copy()
31 |
32 | logger.info(f'Add {int(frac_trn * 100)}% of {tile_type} tiles to the trn, val and tst datasets')
33 | trn_fp_tiles_ids, val_fp_tiles_ids, tst_fp_tiles_ids = split_dataset(_tiles_gdf, frac_trn=frac_trn, seed=seed)
34 |
35 | # Add the FP tiles to the GT gdf
36 | trn_tiles_ids.extend(trn_fp_tiles_ids)
37 | val_tiles_ids.extend(val_fp_tiles_ids)
38 | tst_tiles_ids.extend(tst_fp_tiles_ids)
39 |
40 | _gt_tiles_gdf = pd.concat([_gt_tiles_gdf, _tiles_gdf])
41 |
42 | return trn_tiles_ids, val_tiles_ids, tst_tiles_ids, _gt_tiles_gdf
43 |
44 |
45 | def split_dataset(tiles_df, frac_trn=0.7, frac_left_val=0.5, seed=1):
46 | """Split the dataframe in the traning, validation and test set.
47 |
48 | Args:
49 | tiles_df (DataFrame): Dataset of the tiles
50 | frac_trn (float, optional): Fraction of the dataset to put in the training set. Defaults to 0.7.
51 | frac_left_val (float, optional): Fration of the leftover dataset to be in the validation set. Defaults to 0.5.
52 | seed (int, optional): random seed. Defaults to 1.
53 |
54 | Returns:
55 | tuple:
56 | - list: tile ids going to the training set
57 | - list: tile ids going to the validation set
58 | - list: tile ids going to the test set
59 | """
60 |
61 | trn_tiles_ids = tiles_df\
62 | .sample(frac=frac_trn, random_state=seed)\
63 | .id.astype(str).to_numpy().tolist()
64 |
65 | val_tiles_ids = tiles_df[~tiles_df.id.astype(str).isin(trn_tiles_ids)]\
66 | .sample(frac=frac_left_val, random_state=seed)\
67 | .id.astype(str).to_numpy().tolist()
68 |
69 | tst_tiles_ids = tiles_df[~tiles_df.id.astype(str).isin(trn_tiles_ids + val_tiles_ids)]\
70 | .id.astype(str).to_numpy().tolist()
71 |
72 | return trn_tiles_ids, val_tiles_ids, tst_tiles_ids
73 |
74 |
75 | def split_tiles(aoi_tiles_gdf, gt_labels_gdf, oth_labels_gdf, fp_labels_gdf, fp_frac_trn, empty_tiles_dict, id_list_ept_tiles, img_metadata_dict, tile_size, seed,
76 | output_dir, debug_mode):
77 |
78 | written_files = []
79 |
80 | if gt_labels_gdf.empty:
81 | split_aoi_tiles_gdf = aoi_tiles_gdf.copy()
82 | split_aoi_tiles_gdf['dataset'] = 'oth'
83 | else:
84 | assert(aoi_tiles_gdf.crs == gt_labels_gdf.crs ), "CRS Mismatch between AoI tiles and labels."
85 |
86 | gt_tiles_gdf = gpd.sjoin(aoi_tiles_gdf, gt_labels_gdf, how='inner', predicate='intersects')
87 |
88 | # get the number of labels per class
89 | labels_per_class_dict = {}
90 | for category in gt_tiles_gdf.CATEGORY.unique():
91 | labels_per_class_dict[category] = gt_tiles_gdf[gt_tiles_gdf.CATEGORY == category].shape[0]
92 | # Get the number of labels per tile
93 | labels_per_tiles_gdf = gt_tiles_gdf.groupby(['id', 'CATEGORY'], as_index=False).size()
94 |
95 | gt_tiles_gdf.drop_duplicates(subset=aoi_tiles_gdf.columns, inplace=True)
96 | gt_tiles_gdf = gt_tiles_gdf[aoi_tiles_gdf.columns]
97 |
98 | # Get the tiles containing at least one "FP" label but no "GT" label (if applicable)
99 | if fp_labels_gdf.empty:
100 | fp_tiles_gdf = gpd.GeoDataFrame(columns=['id'])
101 | else:
102 | tmp_fp_tiles_gdf, _ = misc.intersect_labels_with_aoi(aoi_tiles_gdf, fp_labels_gdf)
103 | fp_tiles_gdf = tmp_fp_tiles_gdf[~tmp_fp_tiles_gdf.id.astype(str).isin(gt_tiles_gdf.id.astype(str))].copy()
104 | del tmp_fp_tiles_gdf
105 |
106 | # remove tiles including at least one "oth" label (if applicable)
107 | if not oth_labels_gdf.empty:
108 | oth_tiles_to_remove_gdf, _ = misc.intersect_labels_with_aoi(gt_tiles_gdf, oth_labels_gdf)
109 | gt_tiles_gdf = gt_tiles_gdf[~gt_tiles_gdf.id.astype(str).isin(oth_tiles_to_remove_gdf.id.astype(str))].copy()
110 | del oth_tiles_to_remove_gdf
111 |
112 | # add ramdom tiles not intersecting labels to the dataset
113 | oth_tiles_gdf = aoi_tiles_gdf[~aoi_tiles_gdf.id.astype(str).isin(gt_tiles_gdf.id.astype(str))].copy()
114 | oth_tiles_gdf = oth_tiles_gdf[~oth_tiles_gdf.id.astype(str).isin(fp_tiles_gdf.id.astype(str))].copy()
115 |
116 | # OTH tiles = AoI tiles with labels, but which are not GT
117 | if empty_tiles_dict:
118 | empty_tiles_gdf = aoi_tiles_gdf[aoi_tiles_gdf.id.astype(str).isin(id_list_ept_tiles)].copy()
119 |
120 | if debug_mode:
121 | assert(len(empty_tiles_gdf != 0)), "No empty tiles could be added. Increase the number of tiles sampled in debug mode"
122 |
123 | oth_tiles_gdf = oth_tiles_gdf[~oth_tiles_gdf.id.astype(str).isin(empty_tiles_gdf.id.astype(str))].copy()
124 | oth_tiles_gdf['dataset'] = 'oth'
125 | assert( len(aoi_tiles_gdf) == len(gt_tiles_gdf) + len(fp_tiles_gdf) + len(empty_tiles_gdf) + len(oth_tiles_gdf) )
126 | else:
127 | oth_tiles_gdf['dataset'] = 'oth'
128 | assert( len(aoi_tiles_gdf) == len(gt_tiles_gdf) + len(fp_tiles_gdf) + len(oth_tiles_gdf) )
129 |
130 | # 70%, 15%, 15% split
131 | categories_arr = labels_per_tiles_gdf.CATEGORY.unique()
132 | categories_arr.sort()
133 | if not seed:
134 | max_seed = 50
135 | best_split = 0
136 | for test_seed in tqdm(range(max_seed), desc='Test seeds for splitting tiles between datasets'):
137 | ok_split = 0
138 | trn_tiles_ids, val_tiles_ids, tst_tiles_ids = split_dataset(gt_tiles_gdf, seed=test_seed)
139 |
140 | for category in categories_arr:
141 |
142 | ratio_trn = labels_per_tiles_gdf.loc[
143 | (labels_per_tiles_gdf.CATEGORY == category) & labels_per_tiles_gdf.id.astype(str).isin(trn_tiles_ids), 'size'
144 | ].sum() / labels_per_class_dict[category]
145 | ratio_val = labels_per_tiles_gdf.loc[
146 | (labels_per_tiles_gdf.CATEGORY == category) & labels_per_tiles_gdf.id.astype(str).isin(val_tiles_ids), 'size'
147 | ].sum() / labels_per_class_dict[category]
148 | ratio_tst = labels_per_tiles_gdf.loc[
149 | (labels_per_tiles_gdf.CATEGORY == category) & labels_per_tiles_gdf.id.astype(str).isin(tst_tiles_ids), 'size'
150 | ].sum() / labels_per_class_dict[category]
151 |
152 | ok_split = ok_split + 1 if ratio_trn >= 0.60 else ok_split
153 | ok_split = ok_split + 1 if ratio_val >= 0.12 else ok_split
154 | ok_split = ok_split + 1 if ratio_tst >= 0.12 else ok_split
155 |
156 | ok_split = ok_split - 1 if 0 in [ratio_trn, ratio_val, ratio_tst] else ok_split
157 |
158 | if ok_split == len(categories_arr)*3:
159 | logger.info(f'A seed of {test_seed} produces a good repartition of the labels.')
160 | seed = test_seed
161 | break
162 | elif ok_split > best_split:
163 | seed = test_seed
164 | best_split = ok_split
165 |
166 | if test_seed == max_seed-1:
167 | logger.warning(f'No satisfying seed found between 0 and {max_seed}.')
168 | logger.info(f'The best seed was {seed} with ~{best_split} class subsets containing the correct proportion (trn~0.7, val~0.15, tst~0.15).')
169 | logger.info('The user should set a seed manually if not satisfied.')
170 |
171 | else:
172 | trn_tiles_ids, val_tiles_ids, tst_tiles_ids = split_dataset(gt_tiles_gdf, seed=seed)
173 |
174 | if not fp_tiles_gdf.empty:
175 | trn_tiles_ids, val_tiles_ids, tst_tiles_ids, gt_tiles_gdf = split_additional_tiles(
176 | fp_tiles_gdf, gt_tiles_gdf, trn_tiles_ids, val_tiles_ids, tst_tiles_ids, 'FP', fp_frac_trn, seed
177 | )
178 | del fp_tiles_gdf
179 | if empty_tiles_dict:
180 | EPT_FRAC_TRN = empty_tiles_dict['frac_trn'] if 'frac_trn' in empty_tiles_dict.keys() else 0.7
181 | trn_tiles_ids, val_tiles_ids, tst_tiles_ids, gt_tiles_gdf = split_additional_tiles(
182 | empty_tiles_gdf, gt_tiles_gdf, trn_tiles_ids, val_tiles_ids, tst_tiles_ids, 'empty', EPT_FRAC_TRN, seed
183 | )
184 | del empty_tiles_gdf
185 |
186 | for df in [gt_tiles_gdf, labels_per_tiles_gdf]:
187 | df.loc[df.id.astype(str).isin(trn_tiles_ids), 'dataset'] = 'trn'
188 | df.loc[df.id.astype(str).isin(val_tiles_ids), 'dataset'] = 'val'
189 | df.loc[df.id.astype(str).isin(tst_tiles_ids), 'dataset'] = 'tst'
190 |
191 | logger.info('Repartition in the datasets by category:')
192 | for dst in ['trn', 'val', 'tst']:
193 | for category in categories_arr:
194 | row_ids = labels_per_tiles_gdf.index[(labels_per_tiles_gdf.dataset==dst) & (labels_per_tiles_gdf.CATEGORY==category)]
195 | logger.info(f' {category} labels in {dst} dataset: {labels_per_tiles_gdf.loc[labels_per_tiles_gdf.index.isin(row_ids), "size"].sum()}')
196 |
197 | # remove columns generated by the Spatial Join
198 | gt_tiles_gdf = gt_tiles_gdf[aoi_tiles_gdf.columns.tolist() + ['dataset']].copy()
199 |
200 | assert( len(gt_tiles_gdf) == len(trn_tiles_ids) + len(val_tiles_ids) + len(tst_tiles_ids) ), \
201 | 'Tiles were lost in the split between training, validation and test sets.'
202 |
203 | split_aoi_tiles_gdf = pd.concat(
204 | [
205 | gt_tiles_gdf,
206 | oth_tiles_gdf
207 | ]
208 | )
209 |
210 | # let's free up some memory
211 | del gt_tiles_gdf
212 | del oth_tiles_gdf
213 |
214 |
215 | assert( len(split_aoi_tiles_gdf) == len(aoi_tiles_gdf) ) # it means that all the tiles were actually used
216 |
217 | SPLIT_AOI_TILES = os.path.join(output_dir, 'split_aoi_tiles.geojson')
218 |
219 | split_aoi_tiles_gdf.to_file(SPLIT_AOI_TILES, driver='GeoJSON')
220 | written_files.append(SPLIT_AOI_TILES)
221 | logger.success(f'{DONE_MSG} A file was written {SPLIT_AOI_TILES}')
222 |
223 | img_md_df = pd.DataFrame.from_dict(img_metadata_dict, orient='index')
224 | img_md_df.reset_index(inplace=True)
225 | img_md_df.rename(columns={"index": "img_file"}, inplace=True)
226 |
227 | img_md_df['id'] = img_md_df.apply(misc.img_md_record_to_tile_id, axis=1)
228 |
229 | split_aoi_tiles_with_img_md_gdf = split_aoi_tiles_gdf.merge(img_md_df, on='id', how='left')
230 | for dst in split_aoi_tiles_with_img_md_gdf.dataset.to_numpy():
231 | os.makedirs(os.path.join(output_dir, f'{dst}-images{f"-{tile_size}" if tile_size else ""}'), exist_ok=True)
232 |
233 | split_aoi_tiles_with_img_md_gdf['dst_file'] = [
234 | src_file.replace('all', dataset)
235 | for src_file, dataset in zip(split_aoi_tiles_with_img_md_gdf.img_file, split_aoi_tiles_with_img_md_gdf.dataset)
236 | ]
237 | for src_file, dst_file in zip(split_aoi_tiles_with_img_md_gdf.img_file, split_aoi_tiles_with_img_md_gdf.dst_file):
238 | misc.make_hard_link(src_file, dst_file)
239 |
240 | return split_aoi_tiles_with_img_md_gdf, written_files
--------------------------------------------------------------------------------
/requirements.in:
--------------------------------------------------------------------------------
1 | # NOTE ---------------------------------------------------------------------------------
2 | # The following command must be issued before running `pip install -r requirements.txt`:
3 | # $ sudo apt-get install -y python3-gdal gdal-bin libgdal-dev gcc g++ python3.8-dev
4 | # --------------------------------------------------------------------------------------
5 | GDAL==3.0.4
6 | certifi>=2022.12.07
7 | future>=0.18.3
8 | fiona==1.9.6
9 | geopandas
10 | joblib
11 | loguru
12 | morecantile
13 | networkx
14 | numpy==1.23.3
15 | oauthlib>=3.2.2
16 | opencv-python
17 | openpyxl
18 | pillow==9.5.0
19 | plotly
20 | protobuf==4.25
21 | pygeohash
22 | pyyaml
23 | rasterio
24 | rdp
25 | requests>=2.31.0
26 | rtree
27 | scikit-learn==0.24.2
28 | supermercado
29 | tqdm
30 | # cf. https://pytorch.org/get-started/locally/
31 | torch @ https://download.pytorch.org/whl/cu113/torch-1.10.2%2Bcu113-cp38-cp38-linux_x86_64.whl
32 | torchvision @ https://download.pytorch.org/whl/cu113/torchvision-0.11.3%2Bcu113-cp38-cp38-linux_x86_64.whl
33 | detectron2 @ https://dl.fbaipublicfiles.com/detectron2/wheels/cu113/torch1.10/detectron2-0.6%2Bcu113-cp38-cp38-linux_x86_64.whl
34 | Werkzeug>=2.2.3
35 | wheel>=0.38.1
36 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | #
2 | # This file is autogenerated by pip-compile with Python 3.8
3 | # by the following command:
4 | #
5 | # pip-compile requirements.in
6 | #
7 | absl-py==2.1.0
8 | # via tensorboard
9 | affine==2.4.0
10 | # via
11 | # rasterio
12 | # supermercado
13 | annotated-types==0.7.0
14 | # via pydantic
15 | antlr4-python3-runtime==4.9.3
16 | # via
17 | # hydra-core
18 | # omegaconf
19 | appdirs==1.4.4
20 | # via black
21 | attrs==24.2.0
22 | # via
23 | # fiona
24 | # morecantile
25 | # rasterio
26 | black==21.4b2
27 | # via detectron2
28 | cachetools==5.5.0
29 | # via google-auth
30 | certifi==2024.8.30
31 | # via
32 | # -r requirements.in
33 | # fiona
34 | # pyproj
35 | # rasterio
36 | # requests
37 | charset-normalizer==3.4.0
38 | # via requests
39 | click==8.1.7
40 | # via
41 | # black
42 | # click-plugins
43 | # cligj
44 | # fiona
45 | # mercantile
46 | # rasterio
47 | # supermercado
48 | click-plugins==1.1.1
49 | # via
50 | # fiona
51 | # rasterio
52 | # supermercado
53 | cligj==0.7.2
54 | # via
55 | # fiona
56 | # rasterio
57 | # supermercado
58 | cloudpickle==3.1.0
59 | # via detectron2
60 | contourpy==1.1.1
61 | # via matplotlib
62 | cycler==0.12.1
63 | # via matplotlib
64 | detectron2 @ https://dl.fbaipublicfiles.com/detectron2/wheels/cu113/torch1.10/detectron2-0.6%2Bcu113-cp38-cp38-linux_x86_64.whl
65 | # via -r requirements.in
66 | et-xmlfile==2.0.0
67 | # via openpyxl
68 | fiona==1.9.6
69 | # via
70 | # -r requirements.in
71 | # geopandas
72 | fonttools==4.55.1
73 | # via matplotlib
74 | future==1.0.0
75 | # via
76 | # -r requirements.in
77 | # detectron2
78 | fvcore==0.1.5.post20221221
79 | # via detectron2
80 | gdal==3.0.4
81 | # via -r requirements.in
82 | geopandas==0.13.2
83 | # via -r requirements.in
84 | google-auth==2.36.0
85 | # via
86 | # google-auth-oauthlib
87 | # tensorboard
88 | google-auth-oauthlib==1.0.0
89 | # via tensorboard
90 | grpcio==1.68.1
91 | # via tensorboard
92 | hydra-core==1.3.2
93 | # via detectron2
94 | idna==3.10
95 | # via requests
96 | importlib-metadata==8.5.0
97 | # via
98 | # fiona
99 | # markdown
100 | # rasterio
101 | importlib-resources==6.4.5
102 | # via
103 | # hydra-core
104 | # matplotlib
105 | iopath==0.1.9
106 | # via
107 | # detectron2
108 | # fvcore
109 | joblib==1.4.2
110 | # via
111 | # -r requirements.in
112 | # scikit-learn
113 | kiwisolver==1.4.7
114 | # via matplotlib
115 | loguru==0.7.2
116 | # via -r requirements.in
117 | markdown==3.7
118 | # via tensorboard
119 | markupsafe==2.1.5
120 | # via werkzeug
121 | matplotlib==3.7.5
122 | # via
123 | # detectron2
124 | # pycocotools
125 | mercantile==1.2.1
126 | # via supermercado
127 | morecantile==6.1.0
128 | # via -r requirements.in
129 | mypy-extensions==1.0.0
130 | # via black
131 | networkx==3.1
132 | # via -r requirements.in
133 | numpy==1.23.3
134 | # via
135 | # -r requirements.in
136 | # contourpy
137 | # fvcore
138 | # matplotlib
139 | # opencv-python
140 | # pandas
141 | # pycocotools
142 | # rasterio
143 | # rdp
144 | # scikit-learn
145 | # scipy
146 | # shapely
147 | # snuggs
148 | # supermercado
149 | # tensorboard
150 | # torchvision
151 | oauthlib==3.2.2
152 | # via
153 | # -r requirements.in
154 | # requests-oauthlib
155 | omegaconf==2.3.0
156 | # via
157 | # detectron2
158 | # hydra-core
159 | opencv-python==4.10.0.84
160 | # via -r requirements.in
161 | openpyxl==3.1.5
162 | # via -r requirements.in
163 | packaging==24.2
164 | # via
165 | # geopandas
166 | # hydra-core
167 | # matplotlib
168 | # plotly
169 | pandas==2.0.3
170 | # via geopandas
171 | pathspec==0.12.1
172 | # via black
173 | pillow==9.5.0
174 | # via
175 | # -r requirements.in
176 | # detectron2
177 | # fvcore
178 | # matplotlib
179 | # torchvision
180 | plotly==5.24.1
181 | # via -r requirements.in
182 | portalocker==3.0.0
183 | # via iopath
184 | protobuf==4.25.0
185 | # via
186 | # -r requirements.in
187 | # tensorboard
188 | pyasn1==0.6.1
189 | # via
190 | # pyasn1-modules
191 | # rsa
192 | pyasn1-modules==0.4.1
193 | # via google-auth
194 | pycocotools==2.0.7
195 | # via detectron2
196 | pydantic==2.10.3
197 | # via morecantile
198 | pydantic-core==2.27.1
199 | # via pydantic
200 | pydot==3.0.3
201 | # via detectron2
202 | pygeohash==1.2.0
203 | # via -r requirements.in
204 | pyparsing==3.1.4
205 | # via
206 | # matplotlib
207 | # pydot
208 | # snuggs
209 | pyproj==3.5.0
210 | # via
211 | # geopandas
212 | # morecantile
213 | python-dateutil==2.9.0.post0
214 | # via
215 | # matplotlib
216 | # pandas
217 | pytz==2024.2
218 | # via pandas
219 | pyyaml==6.0.2
220 | # via
221 | # -r requirements.in
222 | # fvcore
223 | # omegaconf
224 | # yacs
225 | rasterio==1.3.11
226 | # via
227 | # -r requirements.in
228 | # supermercado
229 | rdp==0.8
230 | # via -r requirements.in
231 | regex==2024.11.6
232 | # via black
233 | requests==2.32.3
234 | # via
235 | # -r requirements.in
236 | # requests-oauthlib
237 | # tensorboard
238 | requests-oauthlib==2.0.0
239 | # via google-auth-oauthlib
240 | rsa==4.9
241 | # via google-auth
242 | rtree==1.3.0
243 | # via -r requirements.in
244 | scikit-learn==0.24.2
245 | # via -r requirements.in
246 | scipy==1.10.1
247 | # via scikit-learn
248 | shapely==2.0.6
249 | # via geopandas
250 | six==1.17.0
251 | # via
252 | # fiona
253 | # python-dateutil
254 | snuggs==1.4.7
255 | # via rasterio
256 | supermercado==0.2.0
257 | # via -r requirements.in
258 | tabulate==0.9.0
259 | # via
260 | # detectron2
261 | # fvcore
262 | tenacity==9.0.0
263 | # via plotly
264 | tensorboard==2.14.0
265 | # via detectron2
266 | tensorboard-data-server==0.7.2
267 | # via tensorboard
268 | termcolor==2.4.0
269 | # via
270 | # detectron2
271 | # fvcore
272 | threadpoolctl==3.5.0
273 | # via scikit-learn
274 | toml==0.10.2
275 | # via black
276 | torch @ https://download.pytorch.org/whl/cu113/torch-1.10.2%2Bcu113-cp38-cp38-linux_x86_64.whl
277 | # via
278 | # -r requirements.in
279 | # torchvision
280 | torchvision @ https://download.pytorch.org/whl/cu113/torchvision-0.11.3%2Bcu113-cp38-cp38-linux_x86_64.whl
281 | # via -r requirements.in
282 | tqdm==4.67.1
283 | # via
284 | # -r requirements.in
285 | # detectron2
286 | # fvcore
287 | # iopath
288 | typing-extensions==4.12.2
289 | # via
290 | # annotated-types
291 | # pydantic
292 | # pydantic-core
293 | # torch
294 | tzdata==2024.2
295 | # via pandas
296 | urllib3==2.2.3
297 | # via requests
298 | werkzeug==3.0.6
299 | # via
300 | # -r requirements.in
301 | # tensorboard
302 | wheel==0.45.1
303 | # via
304 | # -r requirements.in
305 | # tensorboard
306 | yacs==0.1.8
307 | # via
308 | # detectron2
309 | # fvcore
310 | zipp==3.20.2
311 | # via
312 | # importlib-metadata
313 | # importlib-resources
314 |
315 | # The following packages are considered to be unsafe in a requirements file:
316 | # setuptools
317 |
--------------------------------------------------------------------------------
/scripts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiss-territorial-data-lab/object-detector/f2788811f87dcc1d0cda418a836b478f3dd6946c/scripts/__init__.py
--------------------------------------------------------------------------------
/scripts/cli.py:
--------------------------------------------------------------------------------
1 | # see https://realpython.com/command-line-interfaces-python-argparse/#adding-subcommands-to-your-clis
2 | import sys
3 | import argparse
4 | from scripts.generate_tilesets import main as generate_tilesets
5 | from scripts.train_model import main as train_model
6 | from scripts.make_detections import main as make_detections
7 | from scripts.assess_detections import main as assess_detections
8 |
9 |
10 | def main():
11 |
12 | global_parser = argparse.ArgumentParser(prog="stdl-objdet")
13 |
14 | subparsers = global_parser.add_subparsers(
15 | title="stages", help="the various stages of the STDL Object Detector Framework"
16 | )
17 |
18 | arg_template = {
19 | "dest": "operands",
20 | "type": str,
21 | "nargs": 1,
22 | "metavar": "",
23 | "help": "configuration file",
24 | }
25 |
26 | add_parser = subparsers.add_parser("generate_tilesets", help="This script generates COCO-annotated training/validation/test/other datasets for object detection tasks.")
27 | add_parser.add_argument(**arg_template)
28 | add_parser.set_defaults(func=generate_tilesets)
29 |
30 | add_parser = subparsers.add_parser("train_model", help="This script trains an object detection model.")
31 | add_parser.add_argument(**arg_template)
32 | add_parser.set_defaults(func=train_model)
33 |
34 | add_parser = subparsers.add_parser("make_detections", help="This script makes detections, using a previously trained model.")
35 | add_parser.add_argument(**arg_template)
36 | add_parser.set_defaults(func=make_detections)
37 |
38 | add_parser = subparsers.add_parser("assess_detections", help="This script assesses the quality of detections with respect to ground-truth/other labels.")
39 | add_parser.add_argument(**arg_template)
40 | add_parser.set_defaults(func=assess_detections)
41 |
42 | # https://stackoverflow.com/a/47440202
43 | args = global_parser.parse_args(args=None if sys.argv[1:] else ['--help'])
44 |
45 | args.func(*args.operands)
46 |
47 |
48 | if __name__ == "__main__":
49 |
50 | main()
--------------------------------------------------------------------------------
/scripts/make_detections.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 |
4 | import warnings
5 | warnings.simplefilter(action='ignore', category=UserWarning)
6 |
7 | import os
8 | import sys
9 | import argparse
10 | import cv2
11 | import json
12 | import time
13 | import yaml
14 | from tqdm import tqdm
15 |
16 | import geopandas as gpd
17 | import pandas as pd
18 |
19 | from detectron2.utils.logger import setup_logger
20 | setup_logger()
21 | from detectron2.engine import DefaultPredictor
22 | from detectron2.config import get_cfg
23 | from detectron2.utils.visualizer import Visualizer
24 | from detectron2.data import MetadataCatalog, DatasetCatalog
25 | from detectron2.data.datasets import register_coco_instances
26 | from detectron2.utils.visualizer import ColorMode
27 |
28 | # the following lines allow us to import modules from within this file's parent folder
29 | from inspect import getsourcefile
30 | current_path = os.path.abspath(getsourcefile(lambda:0))
31 | current_dir = os.path.dirname(current_path)
32 | parent_dir = current_dir[:current_dir.rfind(os.path.sep)]
33 | sys.path.insert(0, parent_dir)
34 |
35 | from helpers.detectron2 import detectron2dets_to_features
36 | from helpers.misc import image_metadata_to_affine_transform, format_logger, get_number_of_classes, add_geohash, remove_overlap_poly
37 | from helpers.constants import DONE_MSG
38 |
39 | from loguru import logger
40 | logger = format_logger(logger)
41 |
42 |
43 | def main(cfg_file_path):
44 |
45 | tic = time.time()
46 | logger.info('Starting...')
47 |
48 | logger.info(f"Using {cfg_file_path} as config file.")
49 |
50 | with open(cfg_file_path) as fp:
51 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
52 |
53 | # ---- parse config file
54 | if 'pth_file' in cfg['model_weights'].keys():
55 | MODEL_PTH_FILE = cfg['model_weights']['pth_file']
56 | else:
57 | logger.critical("A model pickle file (\"pth_file\") must be provided")
58 | sys.exit(1)
59 |
60 |
61 | COCO_FILES_DICT = cfg['COCO_files']
62 | DETECTRON2_CFG_FILE = cfg['detectron2_config_file']
63 |
64 | WORKING_DIR = cfg['working_directory']
65 | OUTPUT_DIR = cfg['output_folder'] if 'output_folder' in cfg.keys() else '.'
66 | SAMPLE_TAGGED_IMG_SUBDIR = cfg['sample_tagged_img_subfolder']
67 | LOG_SUBDIR = cfg['log_subfolder']
68 |
69 | SCORE_LOWER_THR = cfg['score_lower_threshold']
70 |
71 | IMG_METADATA_FILE = cfg['image_metadata_json']
72 | RDP_SIMPLIFICATION_ENABLED = cfg['rdp_simplification']['enabled']
73 | RDP_SIMPLIFICATION_EPSILON = cfg['rdp_simplification']['epsilon']
74 | REMOVE_OVERLAP = cfg['remove_det_overlap'] if 'remove_det_overlap' in cfg.keys() else False
75 |
76 | os.chdir(WORKING_DIR)
77 | # let's make the output directories in case they don't exist
78 | for directory in [OUTPUT_DIR, SAMPLE_TAGGED_IMG_SUBDIR, LOG_SUBDIR]:
79 | os.makedirs(directory, exist_ok=True)
80 |
81 | written_files = []
82 |
83 | # ------ Loading image metadata
84 | with open(IMG_METADATA_FILE, 'r') as fp:
85 | tmp = json.load(fp)
86 |
87 | # let's extract filenames (w/o path)
88 | img_metadata_dict = {os.path.split(k)[-1]: v for (k, v) in tmp.items()}
89 |
90 | # ---- register datasets
91 | for dataset_key, coco_file in COCO_FILES_DICT.items():
92 | register_coco_instances(dataset_key, {}, coco_file, "")
93 |
94 | # ---- set up Detectron2's configuration
95 |
96 | # cf. https://detectron2.readthedocs.io/modules/config.html#config-references
97 | cfg = get_cfg()
98 | cfg.merge_from_file(DETECTRON2_CFG_FILE)
99 | cfg.OUTPUT_DIR = LOG_SUBDIR
100 |
101 | cfg.MODEL.WEIGHTS = MODEL_PTH_FILE
102 | logger.info(f'Using model {MODEL_PTH_FILE}.')
103 |
104 | # get the number of classes
105 | num_classes = get_number_of_classes(COCO_FILES_DICT)
106 |
107 | # set the number of classes to detect
108 | cfg.MODEL.ROI_HEADS.NUM_CLASSES = num_classes
109 |
110 | # set the testing threshold for this model
111 | threshold = SCORE_LOWER_THR
112 | threshold_str = str( round(threshold, 2) ).replace('.', 'dot')
113 | cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = threshold
114 |
115 | predictor = DefaultPredictor(cfg)
116 |
117 | # ---- make detections
118 | for dataset in COCO_FILES_DICT.keys():
119 |
120 | all_feats = []
121 | crs = None
122 |
123 | logger.info(f"Making detections over the entire {dataset} dataset...")
124 |
125 | detections_filename = os.path.join(OUTPUT_DIR, f'{dataset}_detections_at_{threshold_str}_threshold.gpkg')
126 |
127 | for d in tqdm(DatasetCatalog.get(dataset)):
128 |
129 | im = cv2.imread(d["file_name"])
130 | try:
131 | outputs = predictor(im)
132 | except Exception as e:
133 | print(f"Exception: {e}, file: {d['file_name']}")
134 | sys.exit(1)
135 |
136 | kk = d["file_name"].split('/')[-1]
137 | im_md = img_metadata_dict[kk]
138 |
139 | _crs = f"EPSG:{im_md['extent']['spatialReference']['latestWkid']}"
140 |
141 | # let's make sure all the images share the same CRS
142 | if crs is not None: # iterations other than the 1st
143 | assert crs == _crs, "Mismatching CRS"
144 | crs = _crs
145 |
146 | transform = image_metadata_to_affine_transform(im_md)
147 | if 'year' in im_md.keys():
148 | year = im_md['year']
149 | this_image_feats = detectron2dets_to_features(outputs, d['file_name'], transform, RDP_SIMPLIFICATION_ENABLED, RDP_SIMPLIFICATION_EPSILON, year=year)
150 | else:
151 | this_image_feats = detectron2dets_to_features(outputs, d['file_name'], transform, RDP_SIMPLIFICATION_ENABLED, RDP_SIMPLIFICATION_EPSILON)
152 |
153 | all_feats += this_image_feats
154 |
155 | gdf = gpd.GeoDataFrame.from_features(all_feats, crs=crs)
156 | gdf['dataset'] = dataset
157 |
158 | # Filter detection to avoid overlapping detection polygons due to multi-class detection
159 | if REMOVE_OVERLAP:
160 | id_to_keep = []
161 | gdf = add_geohash(gdf)
162 | if 'year_det' in gdf.keys():
163 | for year in gdf.year_det.unique():
164 | gdf_temp = gdf.copy()
165 | gdf_temp = gdf_temp[gdf_temp['year_det']==year]
166 | gdf_temp['geom'] = gdf_temp.geometry
167 | ids = remove_overlap_poly(gdf_temp, id_to_keep)
168 | id_to_keep.append(ids)
169 | else:
170 | id_to_keep = remove_overlap_poly(gdf_temp, id_to_keep)
171 | # Keep only polygons with the highest detection score
172 | gdf = gdf[gdf.geohash.isin(id_to_keep)]
173 | gdf.to_file(detections_filename, driver='GPKG')
174 | written_files.append(os.path.join(WORKING_DIR, detections_filename))
175 |
176 | logger.success(DONE_MSG)
177 |
178 | logger.info("Let's tag some sample images...")
179 | for d in DatasetCatalog.get(dataset)[0:min(len(DatasetCatalog.get(dataset)), 10)]:
180 | output_filename = f'{dataset}_det_{d["file_name"].split("/")[-1]}'
181 | output_filename = output_filename.replace('tif', 'png')
182 | im = cv2.imread(d["file_name"])
183 | outputs = predictor(im)
184 | v = Visualizer(im[:, :, ::-1], # [:, :, ::-1] is for RGB -> BGR conversion, cf. https://stackoverflow.com/questions/14556545/why-opencv-using-bgr-colour-space-instead-of-rgb
185 | metadata=MetadataCatalog.get(dataset),
186 | scale=1.0,
187 | instance_mode=ColorMode.IMAGE_BW # remove the colors of unsegmented pixels
188 | )
189 | v = v.draw_instance_predictions(outputs["instances"].to("cpu"))
190 | filepath = os.path.join(SAMPLE_TAGGED_IMG_SUBDIR, output_filename)
191 | cv2.imwrite(filepath, v.get_image()[:, :, ::-1])
192 | written_files.append(os.path.join(WORKING_DIR, filepath))
193 | logger.success(DONE_MSG)
194 |
195 |
196 | # ------ wrap-up
197 |
198 | print()
199 | logger.info("The following files were written. Let's check them out!")
200 | for written_file in written_files:
201 | logger.info(written_file)
202 |
203 | print()
204 |
205 | toc = time.time()
206 | logger.success(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
207 |
208 | sys.stderr.flush()
209 |
210 |
211 | if __name__ == "__main__":
212 |
213 | parser = argparse.ArgumentParser(description="This script makes detections, using a previously trained model.")
214 | parser.add_argument('config_file', type=str, help='a YAML config file')
215 | args = parser.parse_args()
216 |
217 | main(args.config_file)
--------------------------------------------------------------------------------
/scripts/train_model.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 |
4 | import os
5 | import sys
6 | import argparse
7 | import cv2
8 | import time
9 | import yaml
10 |
11 | from detectron2.utils.logger import setup_logger
12 | setup_logger()
13 | from detectron2 import model_zoo
14 | from detectron2.engine import DefaultPredictor
15 | from detectron2.config import get_cfg
16 | from detectron2.utils.visualizer import Visualizer
17 | from detectron2.data import MetadataCatalog, DatasetCatalog
18 | from detectron2.data.datasets import register_coco_instances
19 | from detectron2.utils.visualizer import ColorMode
20 | import torch.multiprocessing
21 | torch.multiprocessing.set_sharing_strategy('file_system')
22 |
23 | # the following lines allow us to import modules from within this file's parent folder
24 | from inspect import getsourcefile
25 | current_path = os.path.abspath(getsourcefile(lambda:0))
26 | current_dir = os.path.dirname(current_path)
27 | parent_dir = current_dir[:current_dir.rfind(os.path.sep)]
28 | sys.path.insert(0, parent_dir)
29 |
30 | from helpers.detectron2 import CocoTrainer
31 | from helpers.misc import format_logger, get_number_of_classes
32 | from helpers.constants import DONE_MSG
33 |
34 | from loguru import logger
35 | logger = format_logger(logger)
36 |
37 |
38 | def main(cfg_file_path):
39 |
40 | tic = time.time()
41 | logger.info('Starting...')
42 |
43 | logger.info(f"Using {cfg_file_path} as config file.")
44 |
45 | with open(cfg_file_path) as fp:
46 | cfg = yaml.load(fp, Loader=yaml.FullLoader)[os.path.basename(__file__)]
47 |
48 | # ---- parse config file
49 |
50 | DEBUG = cfg['debug_mode'] if 'debug_mode' in cfg.keys() else False
51 |
52 | if 'model_zoo_checkpoint_url' in cfg['model_weights'].keys():
53 | MODEL_ZOO_CHECKPOINT_URL = cfg['model_weights']['model_zoo_checkpoint_url']
54 | else:
55 | MODEL_ZOO_CHECKPOINT_URL = None
56 |
57 | # TODO: allow resuming from previous training
58 | # if 'pth_file' in cfg['model_weights'].keys():
59 | # MODEL_PTH_FILE = cfg['model_weights']['pth_file']
60 | # else:
61 | # MODEL_PTH_FILE = None
62 |
63 | if MODEL_ZOO_CHECKPOINT_URL == None:
64 | logger.critical("A model zoo checkpoint URL (\"model_zoo_checkpoint_url\") must be provided")
65 | sys.exit(1)
66 |
67 | COCO_FILES_DICT = cfg['COCO_files']
68 | COCO_TRN_FILE = COCO_FILES_DICT['trn']
69 | COCO_VAL_FILE = COCO_FILES_DICT['val']
70 | COCO_TST_FILE = COCO_FILES_DICT['tst']
71 |
72 | DETECTRON2_CFG_FILE = cfg['detectron2_config_file']
73 |
74 |
75 | WORKING_DIR = cfg['working_directory']
76 | SAMPLE_TAGGED_IMG_SUBDIR = cfg['sample_tagged_img_subfolder']
77 | LOG_SUBDIR = cfg['log_subfolder']
78 |
79 |
80 | os.chdir(WORKING_DIR)
81 | # Erase folder if exists and make them anew
82 | for dir in [SAMPLE_TAGGED_IMG_SUBDIR, LOG_SUBDIR]:
83 | if os.path.exists(dir):
84 | os.system(f"rm -r {dir}")
85 | os.makedirs(dir)
86 |
87 | written_files = []
88 |
89 |
90 | # ---- register datasets
91 | register_coco_instances("trn_dataset", {}, COCO_TRN_FILE, "")
92 | register_coco_instances("val_dataset", {}, COCO_VAL_FILE, "")
93 | register_coco_instances("tst_dataset", {}, COCO_TST_FILE, "")
94 |
95 | registered_datasets = ['trn_dataset', 'val_dataset', 'tst_dataset']
96 |
97 | for dataset in registered_datasets:
98 |
99 | for d in DatasetCatalog.get(dataset)[0:min(len(DatasetCatalog.get(dataset)), 4)]:
100 | output_filename = "tagged_" + d["file_name"].split('/')[-1]
101 | output_filename = output_filename.replace('tif', 'png')
102 |
103 | img = cv2.imread(d["file_name"])
104 |
105 | visualizer = Visualizer(img[:, :, ::-1], metadata=MetadataCatalog.get(dataset), scale=1.0)
106 |
107 | vis = visualizer.draw_dataset_dict(d)
108 | cv2.imwrite(os.path.join(SAMPLE_TAGGED_IMG_SUBDIR, output_filename), vis.get_image()[:, :, ::-1])
109 | written_files.append(os.path.join(WORKING_DIR, SAMPLE_TAGGED_IMG_SUBDIR, output_filename))
110 |
111 |
112 | # ---- set up Detectron2's configuration
113 |
114 | # cf. https://detectron2.readthedocs.io/modules/config.html#config-references
115 | cfg = get_cfg()
116 | cfg.merge_from_file(DETECTRON2_CFG_FILE)
117 | cfg.OUTPUT_DIR = LOG_SUBDIR
118 |
119 | num_classes = get_number_of_classes(COCO_FILES_DICT)
120 |
121 | cfg.MODEL.ROI_HEADS.NUM_CLASSES=num_classes
122 |
123 | if DEBUG:
124 | logger.warning('Setting a configuration for DEBUG only.')
125 | cfg.IMS_PER_BATCH = 2
126 | cfg.SOLVER.STEPS = (100, 200, 250, 300, 350, 375, 400, 425, 450, 460, 470, 480, 490)
127 | cfg.SOLVER.MAX_ITER = 500
128 |
129 | # ---- do training
130 | cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url(MODEL_ZOO_CHECKPOINT_URL)
131 | trainer = CocoTrainer(cfg)
132 | trainer.resume_or_load(resume=False)
133 | trainer.train()
134 | TRAINED_MODEL_PTH_FILE = os.path.join(LOG_SUBDIR, 'model_final.pth')
135 | written_files.append(os.path.join(WORKING_DIR, TRAINED_MODEL_PTH_FILE))
136 |
137 |
138 | # ---- evaluate model on the test dataset
139 | #evaluator = COCOEvaluator("tst_dataset", cfg, False, output_dir='.')
140 | #val_loader = build_detection_test_loader(cfg, "tst_dataset")
141 | #inference_on_dataset(trainer.model, val_loader, evaluator)
142 |
143 | cfg.MODEL.WEIGHTS = TRAINED_MODEL_PTH_FILE
144 | logger.info("Make some sample detections over the test dataset...")
145 |
146 | predictor = DefaultPredictor(cfg)
147 |
148 | for d in DatasetCatalog.get("tst_dataset")[0:min(len(DatasetCatalog.get("tst_dataset")), 10)]:
149 | output_filename = "det_" + d["file_name"].split('/')[-1]
150 | output_filename = output_filename.replace('tif', 'png')
151 | im = cv2.imread(d["file_name"])
152 | outputs = predictor(im)
153 | v = Visualizer(im[:, :, ::-1], # [:, :, ::-1] is for RGB -> BGR conversion, cf. https://stackoverflow.com/questions/14556545/why-opencv-using-bgr-colour-space-instead-of-rgb
154 | metadata=MetadataCatalog.get("tst_dataset"),
155 | scale=1.0,
156 | instance_mode=ColorMode.IMAGE_BW # remove the colors of unsegmented pixels
157 | )
158 | v = v.draw_instance_predictions(outputs["instances"].to("cpu"))
159 | cv2.imwrite(os.path.join(SAMPLE_TAGGED_IMG_SUBDIR, output_filename), v.get_image()[:, :, ::-1])
160 | written_files.append(os.path.join(WORKING_DIR, SAMPLE_TAGGED_IMG_SUBDIR, output_filename))
161 |
162 | logger.success(DONE_MSG)
163 |
164 |
165 | # ------ wrap-up
166 |
167 | print()
168 | logger.info("The following files were written. Let's check them out!")
169 | for written_file in written_files:
170 | logger.info(written_file)
171 |
172 | print()
173 |
174 | toc = time.time()
175 | logger.success(f"Nothing left to be done: exiting. Elapsed time: {(toc-tic):.2f} seconds")
176 |
177 | sys.stderr.flush()
178 |
179 |
180 | if __name__ == "__main__":
181 |
182 | parser = argparse.ArgumentParser(description="This script trains an object detection model.")
183 | parser.add_argument('config_file', type=str, help='a YAML config file')
184 | args = parser.parse_args()
185 |
186 | main(args.config_file)
187 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | with open('requirements.txt') as f:
4 | requirements = f.read().splitlines()
5 |
6 | setup(
7 | name='stdl-object-detector',
8 | version='1.0.0',
9 | description='A suite of Python scripts allowing the end-user to use Deep Learning to detect objects in georeferenced raster images.',
10 | author='Swiss Territorial Data Lab (STDL)',
11 | author_email='info@stdl.ch',
12 | python_requires=">=3.8",
13 | license="MIT license",
14 | entry_points = {
15 | 'console_scripts': [
16 | 'stdl-objdet=scripts.cli:main'
17 | ]
18 | },
19 | install_requires=requirements,
20 | packages=find_packages()
21 | )
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=swiss-territorial-data-lab_object-detector_AYZ4zWIzr7JdaaSXwexc
--------------------------------------------------------------------------------