├── .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 idN 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 WFeature IDN 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 | zA FIDN 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 | zA FIDN 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 --------------------------------------------------------------------------------