diff --git a/.github/workflows/docker-image-build-publish.yml b/.github/workflows/docker-image-build-publish.yml new file mode 100644 index 0000000..592c1d2 --- /dev/null +++ b/.github/workflows/docker-image-build-publish.yml @@ -0,0 +1,48 @@ +# +name: Create and publish a Docker image + +# Configures this workflow to run every time a change is pushed to the branch called `release`. +on: + push: + branches: + - master +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: API/ + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..1236272 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,18 @@ +name: Docker Image CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build the Docker image + run: docker build ./API/. --file API/Dockerfile --tag my-image-name:$(date +%s) diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..7bb356f --- /dev/null +++ b/API/Dockerfile @@ -0,0 +1,22 @@ +ARG PYTHON_VERSION=3.10 + +FROM docker.io/python:${PYTHON_VERSION}-slim-bookworm + +RUN apt-get update \ + && apt-get -y upgrade \ + && apt-get --no-install-recommends -y install \ + build-essential libgdal-dev libboost-numpy-dev + +COPY requirements.txt requirements.txt + +RUN \ + python3 -m pip install --upgrade pip \ + && python3 -m pip install -r requirements.txt + +WORKDIR /app + +COPY main.py /app/main.py + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/API/Readme.md b/API/Readme.md new file mode 100644 index 0000000..96e9b6b --- /dev/null +++ b/API/Readme.md @@ -0,0 +1,33 @@ +## FastAPI Prediction API + +Contains a FastAPI-based API for making predictions using a fAIr model. It provides an endpoint to predict results based on specified parameters. + +### Prerequisites + +- Docker installed on your system + +### Getting Started + +1. Clone Repo and Navigate to /API + + ```bash + git clone https://github.com/kshitijrajsharma/fairpredictor.git + cd API + ``` + +2. Build Docker Image + + ```bash + docker build -t predictor-api . + ``` + +3. Run Docker Container + + ```bash + docker run -p 8080:8000 predictor-api + ``` + +4. API Documentation + + - Redocly Documentation - > Go to your_API_url/redoc : for eg [localhost:redoc](http://localhost:8080/redoc) + - Swagger Documentation - > Go to your_API_url/docs : for eg [localhost:docs](http://localhost:8080/docs#/default/predict_api_predict__post) diff --git a/API/main.py b/API/main.py new file mode 100644 index 0000000..ca55359 --- /dev/null +++ b/API/main.py @@ -0,0 +1,203 @@ +import os +import tempfile +from typing import List, Optional + +import requests +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field, PositiveFloat, validator + +from predictor import predict + +app = FastAPI( + title="fAIr Prediction API", + description="Standalone API for Running .h5, .tf, .tflite Model Predictions", +) + + +origins = ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class PredictionRequest(BaseModel): + """ + Request model for the prediction endpoint. + + Example : + { + "bbox": [ + 100.56228021333352, + 13.685230854641182, + 100.56383321235313, + 13.685961853747969 + ], + "checkpoint": "https://fair-dev.hotosm.org/api/v1/workspace/download/dataset_58/output/training_324//checkpoint.tflite", + "zoom_level": 20, + "source": "https://tiles.openaerialmap.org/6501a65c0906de000167e64d/0/6501a65c0906de000167e64e/{z}/{x}/{y}" + } + """ + + bbox: List[float] + + checkpoint: str = Field( + ..., + example="path/to/model.tflite or https://example.com/model.tflite", + description="Path or URL to the machine learning model file.", + ) + + zoom_level: int = Field( + ..., + description="Zoom level of the tiles to be used for prediction.", + ) + + source: str = Field( + ..., + description="Your Image URL on which you want to detect features.", + ) + + use_josm_q: Optional[bool] = Field( + False, + description="Indicates whether to use JOSM query. Defaults to False.", + ) + + merge_adjacent_polygons: Optional[bool] = Field( + True, + description="Merges adjacent self-intersecting or containing each other polygons. Defaults to True.", + ) + + confidence: Optional[int] = Field( + 50, + description="Threshold probability for filtering out low-confidence predictions. Defaults to 50.", + ) + + max_angle_change: Optional[int] = Field( + 15, + description="Maximum angle change parameter for prediction. Defaults to 15.", + ) + + skew_tolerance: Optional[int] = Field( + 15, + description="Skew tolerance parameter for prediction. Defaults to 15.", + ) + + tolerance: Optional[float] = Field( + 0.5, + description="Tolerance parameter for simplifying polygons. Defaults to 0.5.", + ) + + area_threshold: Optional[float] = Field( + 3, + description="Threshold for filtering polygon areas. Defaults to 3.", + ) + + tile_overlap_distance: Optional[float] = Field( + 0.15, + description="Provides tile overlap distance to remove the strip between predictions. Defaults to 0.15.", + ) + + @validator( + "max_angle_change", + "skew_tolerance", + ) + def validate_values(cls, value): + if value is not None: + if value < 0 or value > 45: + raise ValueError(f"Value should be between 0 and 45: {value}") + return value + + @validator("tolerance") + def validate_tolerance(cls, value): + if value is not None: + if value < 0 or value > 10: + raise ValueError(f"Value should be between 0 and 10: {value}") + return value + + @validator("tile_overlap_distance") + def validate_tile_overlap_distance(cls, value): + if value is not None: + if value < 0 or value > 1: + raise ValueError(f"Value should be between 0 and 1: {value}") + return value + + @validator("area_threshold") + def validate_area_threshold(cls, value): + if value is not None: + if value < 0 or value > 20: + raise ValueError(f"Value should be between 0 and 20: {value}") + return value + + @validator("confidence") + def validate_confidence(cls, value): + if value is not None: + if value < 0 or value > 100: + raise ValueError(f"Value should be between 0 and 100: {value}") + return value / 100 + + @validator("bbox") + def validate_bbox(cls, value): + if len(value) != 4: + raise ValueError("bbox should have exactly 4 elements") + return value + + @validator("zoom_level") + def validate_zoom_level(cls, value): + if value < 18 or value > 22: + raise ValueError("Zoom level should be between 18 and 22") + return value + + @validator("checkpoint") + def validate_checkpoint(cls, value): + """ + Validates checkpoint parameter. If URL, download the file to temp directory. + """ + if value.startswith("http"): + response = requests.get(value) + if response.status_code != 200: + raise ValueError( + "Failed to download model checkpoint from the provided URL" + ) + _, temp_file_path = tempfile.mkstemp(suffix=".tflite") + with open(temp_file_path, "wb") as f: + f.write(response.content) + return temp_file_path + elif not os.path.exists(value): + raise ValueError("Model checkpoint file not found") + return value + + +@app.post("/predict/") +async def predict_api(request: PredictionRequest): + """ + Endpoint to predict results based on specified parameters. + + Parameters: + - `request` (PredictionRequest): Request body containing prediction parameters. + + Returns: + - Predicted results. + """ + try: + predictions = predict( + bbox=request.bbox, + model_path=request.checkpoint, + zoom_level=request.zoom_level, + tms_url=request.source, + tile_size=256, + confidence=request.confidence, + tile_overlap_distance=request.tile_overlap_distance, + merge_adjancent_polygons=request.merge_adjacent_polygons, + max_angle_change=request.max_angle_change, + skew_tolerance=request.skew_tolerance, + tolerance=request.tolerance, + area_threshold=request.area_threshold, + ) + return predictions + except Exception as e: + return {"error": str(e)} diff --git a/API/requirements.txt b/API/requirements.txt new file mode 100644 index 0000000..e50bcd0 --- /dev/null +++ b/API/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.103.2 +uvicorn==0.22.0 +fairpredictor +tflite-runtime==2.14.0 \ No newline at end of file diff --git a/README.md b/README.md index 100df02..1ffbbf8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ## fAIr Predictor - + Run your fAIr Model Predictions anywhere ! ## Prerequisites @@ -47,7 +47,6 @@ There is another postprocessing option that supports distance threshold between pip install raster2polygon ``` - ## Load Testing In order to perform load testing we use Locust , To enable this hit following command within the root dir @@ -56,4 +55,5 @@ In order to perform load testing we use Locust , To enable this hit following co ``` locust -f locust.py ``` -Populate your HOST and replace it with BASE URL of the Predictor URL \ No newline at end of file +Populate your HOST and replace it with BASE URL of the Predictor URL + diff --git a/predictor/app.py b/predictor/app.py index 18f2368..c6cc669 100644 --- a/predictor/app.py +++ b/predictor/app.py @@ -5,6 +5,8 @@ import time import uuid +from orthogonalizer import othogonalize_poly + from .downloader import download from .prediction import run_prediction from .raster2polygon import polygonizer @@ -22,8 +24,12 @@ def predict( area_threshold=3, tolerance=0.5, tile_overlap_distance=0.15, + merge_adjancent_polygons=True, use_raster2polygon=False, remove_metadata=True, + use_josm_q=False, + max_angle_change=15, + skew_tolerance=15, ): """ Parameters: @@ -37,6 +43,7 @@ def predict( area_threshold (float, optional): Threshold for filtering polygon areas. Defaults to 3 sqm. tolerance (float, optional): Tolerance parameter for simplifying polygons. Defaults to 0.5 m. Percentage Tolerance = (Tolerance in Meters / Arc Length in Meters ​)×100 tile_overlap_distance : Provides tile overlap distance to remove the strip between predictions, Defaults to 0.15m + merge_adjancent_polygons(bool,optional) : Merges adjacent self intersecting or containing each other polygons """ if base_path: base_path = os.path.join(base_path, "prediction", str(uuid.uuid4())) @@ -86,10 +93,20 @@ def predict( output_path=geojson_path, area_threshold=area_threshold, tolerance=tolerance, + merge_adjancent_polygons=merge_adjancent_polygons, ) print(f"It took {round(time.time()-start)} sec to extract polygons") with open(geojson_path, "r") as f: prediction_geojson_data = json.load(f) if remove_metadata: shutil.rmtree(base_path) + for feature in prediction_geojson_data["features"]: + feature["properties"]["building"] = "yes" + feature["properties"]["source"] = "fAIr" + if use_josm_q is True: + feature["geometry"] = othogonalize_poly( + feature["geometry"], + maxAngleChange=max_angle_change, + skewTolerance=skew_tolerance, + ) return prediction_geojson_data diff --git a/predictor/prediction.py b/predictor/prediction.py index 43e96ec..fd098ef 100644 --- a/predictor/prediction.py +++ b/predictor/prediction.py @@ -11,15 +11,15 @@ try: import tflite_runtime.interpreter as tflite - except ImportError: - try: - import tensorflow as tf - from tensorflow import keras - except ImportError: - raise ImportError( - "Neither TensorFlow nor TFLite is installed. Please install either TensorFlow or TFLite." - ) + print( + "TFlite_runtime is not installed , Predictions with .tflite extension won't work" + ) +try: + from tensorflow import keras +except ImportError: + print("Tensorflow is not installed , Predictions with .h5 or .tf won't work") + from .georeferencer import georeference from .utils import open_images_keras, open_images_pillow, remove_files, save_mask @@ -74,10 +74,6 @@ def run_prediction( input_tensor_index = interpreter.get_input_details()[0]["index"] output = interpreter.tensor(interpreter.get_output_details()[0]["index"]) else: - try: - from tensorflow import keras - except ImportError: - raise ImportError("Neither TensorFlow not installed.") model = keras.models.load_model(checkpoint_path) print(f"It took {round(time.time()-start)} sec to load model") start = time.time() diff --git a/predictor/utils.py b/predictor/utils.py index cb8f5f5..7f39128 100644 --- a/predictor/utils.py +++ b/predictor/utils.py @@ -14,7 +14,7 @@ try: from tensorflow import keras except ImportError: - print("Unable to import tensorflow") + pass IMAGE_SIZE = 256 @@ -94,6 +94,21 @@ def latlng2tile(zoom, lat, lng, tile_size): return t_x, t_y +def tile_xy_to_quad_key(tile_x, tile_y, level_of_detail): + quad_key = [] + for i in range(level_of_detail, 0, -1): + digit = "0" + mask = 1 << (i - 1) + if (tile_x & mask) != 0: + digit = chr(ord(digit) + 1) + if (tile_y & mask) != 0: + digit = chr(ord(digit) + 1) + digit = chr(ord(digit) + 1) + quad_key.append(digit) + + return "".join(quad_key) + + def download_imagery(start: list, end: list, zm_level, base_path, source="maxar"): """Downloads imagery from start to end tile coordinate system @@ -126,7 +141,9 @@ def download_imagery(start: list, end: list, zm_level, base_path, source="maxar" raise ex source_name = source download_url = f"https://services.digitalglobe.com/earthservice/tmsaccess/tms/1.0.0/DigitalGlobe:ImageryTileService@EPSG:3857@jpg/{zm_level}/{download_path[0]}/{download_path[1]}.jpg?connectId={connect_id}&flipy=true" - + elif source == "bing": + download_url = f"https://ecn.t2.tiles.virtualearth.net/tiles/a{tile_xy_to_quad_key(download_path[0],download_path[1],zm_level)}.jpeg?g=14037&pr=odbl&n=z" + print(download_url) else: # source should be url as string , like this : https://tiles.openaerialmap.org/62dbd947d8499800053796ec/0/62dbd947d8499800053796ed/{z}/{x}/{y} if "{-y}" in source: diff --git a/predictor/vectorizer.py b/predictor/vectorizer.py index 97aa526..4c2c423 100644 --- a/predictor/vectorizer.py +++ b/predictor/vectorizer.py @@ -23,6 +23,7 @@ def vectorize( output_path: str = None, tolerance: float = 0.5, area_threshold: float = 5, + merge_adjancent_polygons=True, ) -> None: """Polygonize raster tiles from the input path. @@ -34,6 +35,7 @@ def vectorize( output_path: Path of the output file. tolerance (float, optional): Tolerance parameter for simplifying polygons. Defaults to 0.5 m. Percentage Tolerance = (Tolerance in Meters / Arc Length in Meters ​)×100 area_threshold (float, optional): Threshold for filtering polygon areas. Defaults to 5 sqm. + merge_adjancent_polygons(bool,optional) : Merges adjacent self intersecting or containing each other polygons Example:: @@ -62,21 +64,27 @@ def vectorize( raster.close() polygons = [shape(s) for s, _ in shapes(mosaic, transform=output)] + merged_polygons = polygons + if merge_adjancent_polygons: + # Merge adjacent polygons + merged_polygons = [] - # Merge adjacent polygons to address gaps along tile boundaries - merged_polygons = [] - for polygon in polygons: - if not merged_polygons: - merged_polygons.append(polygon) - else: - merged = False - for i, merged_polygon in enumerate(merged_polygons): - if polygon.intersects(merged_polygon): - merged_polygons[i] = merged_polygon.union(polygon) - merged = True - break - if not merged: + for polygon in polygons: + if not merged_polygons: merged_polygons.append(polygon) + else: + merged = False + for i, merged_polygon in enumerate(merged_polygons): + if ( + polygon.intersects(merged_polygon) + or polygon.contains(merged_polygon) + or merged_polygon.contains(polygon) + ): + merged_polygons[i] = merged_polygon.union(polygon) + merged = True + break + if not merged: + merged_polygons.append(polygon) areas = [poly.area for poly in merged_polygons] max_area, median_area = np.max(areas), np.median(areas) @@ -101,33 +109,7 @@ def vectorize( polygons_filtered.append(Polygon(multi_polygon.exterior)) gs = gpd.GeoSeries(polygons_filtered, crs=kwargs["crs"]).simplify(tolerance) - gs = remove_overlapping_polygons(gs) if gs.empty: raise ValueError("No Features Found") gs.to_crs("EPSG:4326").to_file(output_path) return output_path - - -def remove_overlapping_polygons(gs: gpd.GeoSeries) -> gpd.GeoSeries: - to_remove = set() - gs_sindex = gs.sindex - non_overlapping_geometries = [] - - for i, p in tqdm(gs.items()): - if i not in to_remove: - possible_matches_index = list(gs_sindex.intersection(p.bounds)) - possible_matches = gs.iloc[possible_matches_index] - precise_matches = possible_matches[possible_matches.overlaps(p)] - - for j, q in precise_matches.items(): - if i != j: - if p.area < q.area: # Compare the areas of the polygons - to_remove.add(i) - else: - to_remove.add(j) - - # Create a new GeoSeries with non-overlapping polygons - non_overlapping_geometries = [p for i, p in gs.items() if i not in to_remove] - non_overlapping_gs = gpd.GeoSeries(non_overlapping_geometries, crs=gs.crs) - - return non_overlapping_gs diff --git a/requirements.txt b/requirements.txt index 35476bd..501d44e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ tqdm<=4.62.3 Pillow<=9.0.1 geopandas<=0.10.2 shapely -rasterio \ No newline at end of file +rasterio +orthogonalizer diff --git a/setup.py b/setup.py index 18c37dc..ae06270 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="fairpredictor", - version="0.0.26", + version="0.0.30", url="https://github.com/kshitijrajsharma/fairpredictor", author="Kshitij Raj Sharma", author_email="skshitizraj@gmail.com", @@ -33,6 +33,6 @@ "geopandas<=0.14.5", "shapely>=1.0.0,<=2.0.2", "rasterio>=1.0.0,<=1.3.8", - # "raster2polygon", + "orthogonalizer", ], ) diff --git a/tests/test_predict.py b/tests/test_predict.py index e69de29..2b5793d 100644 --- a/tests/test_predict.py +++ b/tests/test_predict.py @@ -0,0 +1,12 @@ +bbox = [-84.1334429383278, 9.953153171808898, -84.13033694028854, 9.954719779271468] +zoom_level = 19 +from predictor import download + +image_download_path = download( + bbox, + zoom_level=zoom_level, + tms_url="bing", + tile_size=256, + download_path="/Users/kshitij/hotosm/fairpredictor/download/test", +) +print(image_download_path)