diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0df7760e..1fb83d65 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.9.20 - name: Remove solaris run: sudo rm -rf ./docker ./weights @@ -46,8 +46,8 @@ jobs: - name: Install tensorflow run: pip install tensorflow==2.9.2 - - name : Install pytorch and ultralytics - run: pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 torchaudio==0.12.1 --extra-index-url https://download.pytorch.org/whl/cu113 ultralytics==8.1.6 + - name: Install pytorch and ultralytics + run: pip install torch==2.5.1 torchvision==0.20.1 torchaudio==2.5.1 --extra-index-url https://download.pytorch.org/whl/cu113 ultralytics==8.3.26 - name: Install fair utilities run: pip install -e . @@ -55,10 +55,11 @@ jobs: - name: Run test ramp run: | pip uninstall -y gdal - pip install numpy + pip install numpy==1.26.4 pip install GDAL==$(gdal-config --version) --global-option=build_ext --global-option="-I/usr/include/gdal" - python test_app.py + python test_ramp.py - - name : Run test yolo - run : | - python test_yolo.py + - name: Run test yolo + run: | + python test_yolo_v1.py + python test_yolo_v2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a08448..36047616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## v3.0.0 (2024-11-15) + +### Feat + +- **yololib**: adds lib required for packaging yolo code + +### Fix + +- **bundlelib**: bundles lib itself with new pandas version + +## v2.0.0 (2024-11-15) + +### Feat + +- **yololib**: adds lib required for packaging yolo code +- replace FastSAM with YOLO including training +- add FastSAM inference + +### Fix + +- **postprocessing/utils**: resolve OAM-x-y-z.mask.tif +- **predict**: support both .png and .tif in inference + ## v1.3.0 (2024-10-04) ### Feat diff --git a/README.md b/README.md index 889cb281..a3788c2e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Initially lib was developed during Open AI Challenge with [Omdeena](https://omde Installing all libraries could be pain so we suggest you to use docker , If you like to do it bare , You can follow `.github/build.yml` + Clone repo diff --git a/hot_fair_utilities/__init__.py b/hot_fair_utilities/__init__.py index d326788a..f4bb24ac 100644 --- a/hot_fair_utilities/__init__.py +++ b/hot_fair_utilities/__init__.py @@ -1,6 +1,7 @@ from .georeferencing import georeference -from .inference import predict, evaluate +from .inference import evaluate, predict from .postprocessing import polygonize, vectorize -from .preprocessing import preprocess -from .training import train +from .preprocessing import preprocess, yolo_v8_v1 + +# from .training import ramp, yolo_v8_v1 from .utils import bbox2tiles, tms2img diff --git a/hot_fair_utilities/georeferencing.py b/hot_fair_utilities/georeferencing.py index 074dba72..b6a1e556 100644 --- a/hot_fair_utilities/georeferencing.py +++ b/hot_fair_utilities/georeferencing.py @@ -11,7 +11,7 @@ from .utils import get_bounding_box -def georeference(input_path: str, output_path: str, is_mask=False) -> None: +def georeference(input_path: str, output_path: str, is_mask=False,epsg=3857) -> None: """Perform georeferencing and remove the fourth band from images (if any). CRS of the georeferenced images will be EPSG:3857 ('WGS 84 / Pseudo-Mercator'). @@ -38,7 +38,7 @@ def georeference(input_path: str, output_path: str, is_mask=False) -> None: out_file = f"{output_path}/{filename}.tif" # Get bounding box in EPSG:3857 - x_min, y_min, x_max, y_max = get_bounding_box(filename) + x_min, y_min, x_max, y_max = get_bounding_box(filename,epsg=epsg) # Use one band for masks and the first three bands for images bands = [1] if is_mask else [1, 2, 3] @@ -51,7 +51,7 @@ def georeference(input_path: str, output_path: str, is_mask=False) -> None: format="GTiff", bandList=bands, outputBounds=[x_min, y_max, x_max, y_min], - outputSRS="EPSG:3857", + outputSRS=f"EPSG:{epsg}", ) # Close dataset _ = None diff --git a/hot_fair_utilities/inference/evaluate.py b/hot_fair_utilities/inference/evaluate.py index 50d0cec6..f1188ffd 100644 --- a/hot_fair_utilities/inference/evaluate.py +++ b/hot_fair_utilities/inference/evaluate.py @@ -1,12 +1,21 @@ # Patched from ramp-code.scripts.calculate_accuracy.iou created for ramp project by carolyn.johnston@dev.global +# Standard library imports from pathlib import Path + +# Third party imports import geopandas as gpd -from ramp.utils.eval_utils import get_iou_accuracy_metrics +try: + # Third party imports + from ramp.utils.eval_utils import get_iou_accuracy_metrics +except ImportError: + print("Ramp eval metrics are not available, Possibly ramp is not installed") -def evaluate(test_path, truth_path, filter_area_m2=None, iou_threshold=0.5, verbose=False): +def evaluate( + test_path, truth_path, filter_area_m2=None, iou_threshold=0.5, verbose=False +): """ Calculate precision/recall/F1-score based on intersection-over-union accuracy evaluation protocol defined by RAMP. @@ -29,9 +38,9 @@ def evaluate(test_path, truth_path, filter_area_m2=None, iou_threshold=0.5, verb truth_df, test_df = gpd.read_file(str(truth_path)), gpd.read_file(str(test_path)) metrics = get_iou_accuracy_metrics(test_df, truth_df, filter_area_m2, iou_threshold) - n_detections = metrics['n_detections'] + n_detections = metrics["n_detections"] n_truth = metrics["n_truth"] - n_truepos = metrics['true_pos'] + n_truepos = metrics["true_pos"] n_falsepos = n_detections - n_truepos n_falseneg = n_truth - n_truepos agg_precision = n_truepos / n_detections diff --git a/hot_fair_utilities/inference/predict.py b/hot_fair_utilities/inference/predict.py index 177ce0df..cae2a388 100644 --- a/hot_fair_utilities/inference/predict.py +++ b/hot_fair_utilities/inference/predict.py @@ -12,7 +12,7 @@ from ..georeferencing import georeference from ..utils import remove_files -from .utils import open_images, save_mask, initialize_model +from .utils import initialize_model, open_images, save_mask BATCH_SIZE = 8 IMAGE_SIZE = 256 @@ -20,7 +20,11 @@ def predict( - checkpoint_path: str, input_path: str, prediction_path: str, confidence: float = 0.5, remove_images=True + checkpoint_path: str, + input_path: str, + prediction_path: str, + confidence: float = 0.5, + remove_images=True, ) -> None: """Predict building footprints for aerial images given a model checkpoint. @@ -46,7 +50,7 @@ def predict( """ start = time.time() print(f"Using : {checkpoint_path}") - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = initialize_model(checkpoint_path, device=device) print(f"It took {round(time.time()-start)} sec to load model") start = time.time() @@ -74,14 +78,24 @@ def predict( ) elif isinstance(model, YOLO): for idx in range(0, len(image_paths), BATCH_SIZE): - batch = image_paths[idx:idx + BATCH_SIZE] - for i, r in enumerate(model(batch, stream=True, conf=confidence, verbose=False)): - if r.masks is None: - preds = np.zeros((IMAGE_SIZE, IMAGE_SIZE,), dtype=np.float32) + batch = image_paths[idx : idx + BATCH_SIZE] + for i, r in enumerate( + model.predict(batch, conf=confidence, imgsz=IMAGE_SIZE, verbose=False) + ): + if hasattr(r, "masks") and r.masks is not None: + preds = ( + r.masks.data.max(dim=0)[0].detach().cpu().numpy() + ) # Combine masks and convert to numpy + else: - preds = r.masks.data.max(dim=0)[0] # dim=0 means to take only footprint - preds = torch.where(preds > confidence, torch.tensor(1), torch.tensor(0)) - preds = preds.detach().cpu().numpy() + preds = np.zeros( + ( + IMAGE_SIZE, + IMAGE_SIZE, + ), + dtype=np.float32, + ) # Default if no masks + save_mask(preds, str(f"{prediction_path}/{Path(batch[i]).stem}.png")) else: raise RuntimeError("Loaded model is not supported") diff --git a/hot_fair_utilities/postprocessing/building_footprint.py b/hot_fair_utilities/postprocessing/building_footprint.py index 214eae3d..caccf119 100644 --- a/hot_fair_utilities/postprocessing/building_footprint.py +++ b/hot_fair_utilities/postprocessing/building_footprint.py @@ -1,5 +1,7 @@ +# Standard library imports import collections +# Third party imports from geopandas import GeoSeries from shapely.geometry import MultiPolygon, Polygon from shapely.ops import unary_union @@ -75,4 +77,4 @@ def extract(self, tile, mask): self.features.append(feature) def save(self, out): - GeoSeries(self.features).set_crs(CRS).to_file(out) + GeoSeries(self.features).set_crs(CRS).to_file(out, driver="GeoJSON") diff --git a/hot_fair_utilities/postprocessing/merge_polygons.py b/hot_fair_utilities/postprocessing/merge_polygons.py index aa42f8b6..7cb8b12b 100644 --- a/hot_fair_utilities/postprocessing/merge_polygons.py +++ b/hot_fair_utilities/postprocessing/merge_polygons.py @@ -3,6 +3,7 @@ from shapely.geometry import MultiPolygon, Polygon from shapely.validation import make_valid from tqdm import tqdm +import os from .utils import UndirectedGraph, make_index, project, union @@ -19,7 +20,7 @@ def merge_polygons(polygons_path, new_polygons_path, distance_threshold): new_polygons_path: Path to GeoJSON file where the merged polygons will be saved distance_threshold: Minimum distance to define adjacent polygons, in meters """ - gdf = read_file(polygons_path) + gdf = read_file(os.path.relpath(polygons_path)) shapes = list(gdf["geometry"]) graph = UndirectedGraph() @@ -71,4 +72,4 @@ def unbuffered(shape): features.append(feature) gs = GeoSeries(features).set_crs(SOURCE_CRS) - gs.simplify(TOLERANCE).to_file(new_polygons_path) + gs.simplify(TOLERANCE).to_file(new_polygons_path, driver="GeoJSON") diff --git a/hot_fair_utilities/postprocessing/vectorize.py b/hot_fair_utilities/postprocessing/vectorize.py index 845b9834..8e045b4b 100644 --- a/hot_fair_utilities/postprocessing/vectorize.py +++ b/hot_fair_utilities/postprocessing/vectorize.py @@ -15,7 +15,9 @@ AREA_THRESHOLD = 5 -def vectorize(input_path: str, output_path: str , tolerance: float = 0.5, area_threshold: float = 5) -> None: +def vectorize( + input_path: str, output_path: str, tolerance: float = 0.5, area_threshold: float = 5 +) -> None: """Polygonize raster tiles from the input path. Note that as input, we are expecting GeoTIF images with EPSG:3857 as @@ -52,15 +54,14 @@ def vectorize(input_path: str, output_path: str , tolerance: float = 0.5, area_t polygons = [ Polygon(poly.exterior.coords) for poly in polygons - if poly.area != max_area - and poly.area / median_area > area_threshold + if poly.area != max_area and poly.area / median_area > area_threshold ] gs = gpd.GeoSeries(polygons, 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) + gs.to_crs("EPSG:4326").to_file(output_path, driver="GeoJSON") def remove_overlapping_polygons(gs: gpd.GeoSeries) -> gpd.GeoSeries: @@ -79,4 +80,4 @@ def remove_overlapping_polygons(gs: gpd.GeoSeries) -> gpd.GeoSeries: else: to_remove.add(j) - return gs.drop(list(to_remove)) \ No newline at end of file + return gs.drop(list(to_remove)) diff --git a/hot_fair_utilities/preprocessing/__init__.py b/hot_fair_utilities/preprocessing/__init__.py index a465ee7f..6a146dcb 100644 --- a/hot_fair_utilities/preprocessing/__init__.py +++ b/hot_fair_utilities/preprocessing/__init__.py @@ -1 +1,2 @@ from .preprocess import preprocess +from .yolo_v8_v1 import yolo_format diff --git a/hot_fair_utilities/preprocessing/clip_labels.py b/hot_fair_utilities/preprocessing/clip_labels.py index 251407b1..8af073ab 100644 --- a/hot_fair_utilities/preprocessing/clip_labels.py +++ b/hot_fair_utilities/preprocessing/clip_labels.py @@ -3,6 +3,7 @@ from glob import glob from pathlib import Path +# Third party imports # Third-party imports import geopandas from osgeo import gdal @@ -13,7 +14,12 @@ def clip_labels( - input_path: str, output_path: str, rasterize=False, rasterize_options=None + input_path: str, + output_path: str, + rasterize=False, + rasterize_options=None, + all_geojson_file=None, + epsg=3857, ) -> None: """Clip and rasterize the GeoJSON labels for each aerial image. @@ -71,24 +77,29 @@ def clip_labels( glob(f"{input_path}/*.png"), desc=f"Clipping labels for {Path(input_path).stem}" ): filename = Path(path).stem - geojson_file_all_labels = f"{output_path}/labels_epsg3857.geojson" + if all_geojson_file: + geojson_file_all_labels = all_geojson_file + else: + geojson_file_all_labels = f"{output_path}/labels_epsg3857.geojson" clipped_geojson_file = f"{output_geojson_path}/{filename}.geojson" # Bounding box as a tuple - x_min, y_min, x_max, y_max = get_bounding_box(filename) + x_min, y_min, x_max, y_max = get_bounding_box(filename, epsg=epsg) # Bounding box as a polygon bounding_box_polygon = box(x_min, y_min, x_max, y_max) # Read all labels into a GeoDataFrame, clip it and # write to GeoJSON - gdf_all_labels = geopandas.read_file(geojson_file_all_labels) + gdf_all_labels = geopandas.read_file(os.path.relpath(geojson_file_all_labels)) gdf_clipped = gdf_all_labels.clip(bounding_box_polygon) if len(gdf_clipped) > 0: - gdf_clipped.to_file(clipped_geojson_file) + gdf_clipped.to_file(clipped_geojson_file, driver="GeoJSON") else: schema = {"geometry": "Polygon", "properties": {"id": "int"}} - crs = "EPSG:3857" - gdf_clipped.to_file(clipped_geojson_file, schema=schema, crs=crs) + crs = f"EPSG:{epsg}" + gdf_clipped.to_file( + clipped_geojson_file, schema=schema, crs=crs, driver="GeoJSON" + ) # Rasterizing if rasterize: diff --git a/hot_fair_utilities/preprocessing/fix_labels.py b/hot_fair_utilities/preprocessing/fix_labels.py index b23538bc..eb3c25d4 100644 --- a/hot_fair_utilities/preprocessing/fix_labels.py +++ b/hot_fair_utilities/preprocessing/fix_labels.py @@ -1,7 +1,7 @@ # Third party imports import geopandas from shapely.validation import explain_validity, make_valid - +import os def remove_self_intersection(row): """Fix self-intersections in the polygons. @@ -29,8 +29,9 @@ def fix_labels(input_path: str, output_path: str) -> None: input_path: Path to the GeoJSON file where the input data are stored. output_path: Path to the GeoJSON file where the output data will go. """ - gdf = geopandas.read_file(input_path) + gdf = geopandas.read_file(os.path.relpath(input_path)) + # print(gdf) if gdf.empty: raise ValueError("Error: gdf is empty, No Labels found : Check your labels") gdf["geometry"] = gdf.apply(remove_self_intersection, axis=1) - gdf.to_file(output_path) + gdf.to_file(output_path, driver="GeoJSON") diff --git a/hot_fair_utilities/preprocessing/multimasks_from_polygons.py b/hot_fair_utilities/preprocessing/multimasks_from_polygons.py index 48e0489f..6accf869 100644 --- a/hot_fair_utilities/preprocessing/multimasks_from_polygons.py +++ b/hot_fair_utilities/preprocessing/multimasks_from_polygons.py @@ -2,7 +2,7 @@ # Standard library imports from pathlib import Path - +import os # Third party imports import geopandas as gpd import rasterio as rio @@ -88,7 +88,7 @@ def multimasks_from_polygons( # workaround for bug in solaris mask_shape, mask_transform = get_rasterio_shape_and_transform(chip_path) - gdf = gpd.read_file(json_path) + gdf = gpd.read_file(os.path.relpath(json_path)) # remove empty and null geometries gdf = gdf[~gdf["geometry"].isna()] diff --git a/hot_fair_utilities/preprocessing/preprocess.py b/hot_fair_utilities/preprocessing/preprocess.py index cdef961a..ea4c1469 100644 --- a/hot_fair_utilities/preprocessing/preprocess.py +++ b/hot_fair_utilities/preprocessing/preprocess.py @@ -4,9 +4,7 @@ from ..georeferencing import georeference from .clip_labels import clip_labels from .fix_labels import fix_labels -from .multimasks_from_polygons import multimasks_from_polygons from .reproject_labels import reproject_labels_to_epsg3857 -from .multimasks_from_polygons import multimasks_from_polygons def preprocess( @@ -18,6 +16,7 @@ def preprocess( multimasks=False, input_contact_spacing=8, # only required if multimasks is set to true input_boundary_width=3, # only required if mulltimasks is set to true + epsg=3857, ) -> None: """Fully preprocess the input data. @@ -63,6 +62,7 @@ def preprocess( ) """ # Check if rasterizing options are valid + assert epsg in (4326, 3857), "Projection not supported" if rasterize: assert ( rasterize_options is not None @@ -80,24 +80,37 @@ def preprocess( os.makedirs(output_path, exist_ok=True) if georeference_images: - georeference(input_path, f"{output_path}/chips") + georeference(input_path, f"{output_path}/chips", epsg=epsg) fix_labels( f"{input_path}/labels.geojson", f"{output_path}/corrected_labels.geojson", ) + if epsg == 3857: + reproject_labels_to_epsg3857( + f"{output_path}/corrected_labels.geojson", + f"{output_path}/labels_epsg3857.geojson", + ) - reproject_labels_to_epsg3857( - f"{output_path}/corrected_labels.geojson", - f"{output_path}/labels_epsg3857.geojson", + clip_labels( + input_path, + output_path, + rasterize, + rasterize_options, + all_geojson_file=( + f"{output_path}/corrected_labels.geojson" + if epsg == 4326 + else f"{output_path}/labels_epsg3857.geojson" + ), + epsg=epsg, ) - clip_labels(input_path, output_path, rasterize, rasterize_options) - os.remove(f"{output_path}/corrected_labels.geojson") - os.remove(f"{output_path}/labels_epsg3857.geojson") + if epsg == 3857: + os.remove(f"{output_path}/labels_epsg3857.geojson") if multimasks: + from .multimasks_from_polygons import multimasks_from_polygons assert os.path.isdir( f"{output_path}/chips" @@ -108,4 +121,4 @@ def preprocess( f"{output_path}/multimasks", input_contact_spacing=input_contact_spacing, input_boundary_width=input_boundary_width, - ) \ No newline at end of file + ) diff --git a/hot_fair_utilities/preprocessing/reproject_labels.py b/hot_fair_utilities/preprocessing/reproject_labels.py index e02af896..7e2ed356 100644 --- a/hot_fair_utilities/preprocessing/reproject_labels.py +++ b/hot_fair_utilities/preprocessing/reproject_labels.py @@ -1,6 +1,7 @@ # Third-party imports +# Third party imports import geopandas - +import os def reproject_labels_to_epsg3857(input_path: str, output_path: str) -> None: """Convert a GeoJSON file with labels from EPSG:4326 to EPSG:3857. @@ -12,5 +13,6 @@ def reproject_labels_to_epsg3857(input_path: str, output_path: str) -> None: input_path: Path to the GeoJSON file where the input data are stored. output_path: Path to the GeoJSON file where the output data will go. """ - labels_gdf = geopandas.read_file(input_path).set_crs("EPSG:4326") - labels_gdf.to_crs("EPSG:3857").to_file(output_path) + + labels_gdf = geopandas.read_file(os.path.relpath(input_path), driver='GeoJSON').set_crs("EPSG:4326") + labels_gdf.to_crs("EPSG:3857").to_file(output_path, driver="GeoJSON") diff --git a/hot_fair_utilities/preprocessing/yolo_v8_v1/__init__.py b/hot_fair_utilities/preprocessing/yolo_v8_v1/__init__.py new file mode 100644 index 00000000..2d06f764 --- /dev/null +++ b/hot_fair_utilities/preprocessing/yolo_v8_v1/__init__.py @@ -0,0 +1 @@ +from .yolo_format import yolo_format diff --git a/hot_fair_utilities/preprocessing/yolo_format.py b/hot_fair_utilities/preprocessing/yolo_v8_v1/yolo_format.py similarity index 71% rename from hot_fair_utilities/preprocessing/yolo_format.py rename to hot_fair_utilities/preprocessing/yolo_v8_v1/yolo_format.py index 2ad55c84..e388a471 100644 --- a/hot_fair_utilities/preprocessing/yolo_format.py +++ b/hot_fair_utilities/preprocessing/yolo_v8_v1/yolo_format.py @@ -1,19 +1,23 @@ +# Standard library imports import concurrent.futures -import cv2 -import numpy as np -import yaml -import rasterio import random -import warnings import traceback +import warnings from pathlib import Path +# Third party imports +import cv2 +import numpy as np +import rasterio +import yaml # Mask types from https://rampml.global/data-preparation/ CLASS_NAMES = ["footprint", "boundary", "contact"] -def yolo_format(preprocessed_dirs, yolo_dir, val_dirs=None, multimask=False, p_val=None): +def yolo_format( + preprocessed_dirs, yolo_dir, val_dirs=None, multimask=False, p_val=None +): """ Creates ultralytics YOLOv5 format dataset from RAMP preprocessed data. Supports either single data directory or multiple directories. @@ -53,10 +57,15 @@ def yolo_format(preprocessed_dirs, yolo_dir, val_dirs=None, multimask=False, p_v yolo_dir_suffixes = ["_train", "_val"] if p_val else [""] # Save image symlinks and labels - for dname, dname_stem in zip(preprocessed_dirs + val_dirs, preprocessed_dirs_stems + val_dirs_stems): + for dname, dname_stem in zip( + preprocessed_dirs + val_dirs, preprocessed_dirs_stems + val_dirs_stems + ): img_dir = dname / "chips" if (dname / "chips").is_dir() else dname / "source" mask_dir = dname / mask_dirname - yolo_img_dir, yolo_label_dir = yolo_dir / "images" / dname_stem, yolo_dir / "labels" / dname_stem + yolo_img_dir, yolo_label_dir = ( + yolo_dir / "images" / dname_stem, + yolo_dir / "labels" / dname_stem, + ) for dir in [yolo_img_dir, yolo_label_dir]: for suf in yolo_dir_suffixes: @@ -64,32 +73,50 @@ def yolo_format(preprocessed_dirs, yolo_dir, val_dirs=None, multimask=False, p_v files = list(img_dir.iterdir()) random.shuffle(files) - _image_iteration(files[0], img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, 1.0 if p_val else None) + _image_iteration( + files[0], + img_dir, + mask_dir, + yolo_img_dir, + yolo_label_dir, + classes, + 1.0 if p_val else None, + ) with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: executor.map( - lambda x: __image_iteration_func(x, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val), - files[1:] + lambda x: __image_iteration_func( + x, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val + ), + files[1:], ) if p_val: val_dirs_stems = [str(p) + "_val" for p in preprocessed_dirs_stems] preprocessed_dirs_stems = [str(p) + "_train" for p in preprocessed_dirs_stems] - # Save dataset.yaml + # Save yolo_dataset.yaml dataset = { - "names": {i-1: name for i, name in zip(classes, CLASS_NAMES[:len(classes)])}, + "names": {i - 1: name for i, name in zip(classes, CLASS_NAMES[: len(classes)])}, "path": str(yolo_dir.absolute()), - "train": f"./images/{str(preprocessed_dirs_stems[0])}/" if len(preprocessed_dirs) == 1 else \ - [f"./images/{str(d)}" for d in preprocessed_dirs_stems], + "train": ( + f"./images/{str(preprocessed_dirs_stems[0])}/" + if len(preprocessed_dirs) == 1 + else [f"./images/{str(d)}" for d in preprocessed_dirs_stems] + ), } if len(val_dirs_stems) > 0: - dataset["val"] = f"./images/{str(val_dirs_stems[0])}/" if len(val_dirs_stems) == 1 else \ - [f"./images/{str(d)}" for d in val_dirs_stems] - with open(yolo_dir / "dataset.yaml", 'w') as handle: + dataset["val"] = ( + f"./images/{str(val_dirs_stems[0])}/" + if len(val_dirs_stems) == 1 + else [f"./images/{str(d)}" for d in val_dirs_stems] + ) + with open(yolo_dir / "yolo_dataset.yaml", "w") as handle: yaml.dump(dataset, handle, default_flow_style=False) -def _image_iteration(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val): +def _image_iteration( + img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val +): if p_val: if random.uniform(0, 1) > p_val: yolo_img_dir = Path(str(yolo_img_dir) + "_train") @@ -111,10 +138,12 @@ def _image_iteration(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, class data = handle.read() h, w = data.shape[1:] label = str(img)[:-4] + ".txt" - with open(yolo_label_dir / label, 'w') as handle: + with open(yolo_label_dir / label, "w") as handle: for cls in classes: x = np.where(data == cls, 255, 0).squeeze().astype("uint8") - contours, _ = cv2.findContours(x, mode=cv2.RETR_EXTERNAL, method=cv2.CHAIN_APPROX_TC89_KCOS) + contours, _ = cv2.findContours( + x, mode=cv2.RETR_EXTERNAL, method=cv2.CHAIN_APPROX_TC89_KCOS + ) for contour in contours: # contour (n, 1, 2) if contour.shape[0] > 2: # at least 3-point polygon contour = contour / [w, h] @@ -122,9 +151,13 @@ def _image_iteration(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, class handle.write(line) -def __image_iteration_func(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val): +def __image_iteration_func( + img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val +): try: - _image_iteration(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val) + _image_iteration( + img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, classes, p_val + ) except Exception as e: full_trace = "\n" + " ".join(traceback.format_exception(e)) warnings.warn(f"Image {img.name} caused {full_trace}") @@ -135,4 +168,3 @@ def __image_iteration_func(img, img_dir, mask_dir, yolo_img_dir, yolo_label_dir, # root = "/tf/ramp-data/sample_119" root = "/home/powmol/wip/hotosm/fAIr-utilities/ramp-data/sample_119" yolo_format([root + "/preprocessed"], root + "/yolo", multimask=False, p_val=0.05) - diff --git a/hot_fair_utilities/preprocessing/yolo_v8_v2/__init__.py b/hot_fair_utilities/preprocessing/yolo_v8_v2/__init__.py new file mode 100644 index 00000000..2d06f764 --- /dev/null +++ b/hot_fair_utilities/preprocessing/yolo_v8_v2/__init__.py @@ -0,0 +1 @@ +from .yolo_format import yolo_format diff --git a/hot_fair_utilities/preprocessing/yolo_v8_v2/utils.py b/hot_fair_utilities/preprocessing/yolo_v8_v2/utils.py new file mode 100644 index 00000000..c071c76b --- /dev/null +++ b/hot_fair_utilities/preprocessing/yolo_v8_v2/utils.py @@ -0,0 +1,309 @@ +# Standard library imports + +# Standard library imports +import json +import os + +# Third party imports +import cv2 +import rasterio +from PIL import Image +from pyproj import Transformer +from tqdm import tqdm + + +def check_shapes(iwps): + """ + Check the shapes of image files and store them in a dictionary. + + Parameters: + iwps (list): A list of image files with paths. + + Returns: + tuple: A tuple containing two elements: + - shapes_dict (dict): A dictionary where the keys are the image shapes and the values are the counts. + - shapes (list): A list of the shapes of the chip files in the same order as the input list. + """ + # Create a dictionary to store the shape of the chip files + shapes_dict = {} + shapes = [] + + for iwp in tqdm(iwps): + # Read the chip file + shape = cv2.imread(iwp, -1).shape + + # Store the shape in the dictionary + if str(shape) in shapes_dict: + shapes_dict[str(shape)] += 1 + else: + shapes_dict[str(shape)] = 1 + + shapes.append(shape) + + # Return the dictionary + return shapes_dict, shapes + + +def get_geo_data(iwp): + """ + Extracts geo data from a geotif. + + Parameters: + iwp (str): The image file with path. + + Returns: + dict: A dictionary containing the extracted geo data. The dictionary includes the following keys: + - 'left': The left coordinate of the bounding box. + - 'right': The right coordinate of the bounding box. + - 'top': The top coordinate of the bounding box. + - 'bottom': The bottom coordinate of the bounding box. + - 'width': The width of the bounding box. + - 'height': The height of the bounding box. + - 'crs': The coordinate reference system (CRS) of the geotif. + """ + # Open the image file in binary mode ('rb') for reading Exif data + with rasterio.open(iwp) as src: + + if src.crs is None: + raise ValueError( + "No CRS found in the image file. Please check the file and try again." + ) + elif src.bounds is None: + raise ValueError( + "No bounds found in the image file. Please check the file and try again." + ) + + # Convert the bounds to the expected format + transformer = Transformer.from_crs(src.crs, "EPSG:4326") + left, bottom = transformer.transform(src.bounds.left, src.bounds.bottom) + right, top = transformer.transform(src.bounds.right, src.bounds.top) + width = right - left + height = top - bottom + + # Collect and return the extracted geo data + results = { + "left": left, + "right": right, + "top": top, + "bottom": bottom, + "width": width, + "height": height, + "crs": src.crs.to_string(), + } + + return results + + +def check_and_clamp(values): + """ + Check and clamp the values in a nested list. + + Parameters: + values (list): A nested list of values to be checked and clamped. + + Returns: + list: A nested list of clamped values. + + """ + # Initialize an empty list to store the clamped values + clamped_values = [] + + # Iterate over each sublist in the list + for sublist in values: + # Use a list comprehension to check and clamp each value in the sublist + clamped_sublist = [ + [max(0, min(1, value)) for value in pair] for pair in sublist + ] + + # Add the processed sublist to the clamped_values list + clamped_values.append(clamped_sublist) + + return clamped_values + + +def flatten_list(nested_list): + """ + Flattens a nested list into a single flat list. + + Parameters: + nested_list (list): The nested list to be flattened. + + Returns: + list: The flattened list. + """ + flat_list = [] + + # Iterate over all the elements in the given list + for item in nested_list: + # Check if the item is a list itself + if isinstance(item, list): + # If the item is a list, extend the flat list by adding elements of this item + flat_list.extend(flatten_list(item)) + else: + # If the item is not a list, append the item itself + flat_list.append(item) + return flat_list + + +def convert_coordinates(coordinates, geo_dict): + """ + Convert coordinates from one coordinate system to another based on the provided geo_dict. + + Args: + coordinates (list): A list of coordinate sets. + geo_dict (dict): A dictionary containing information about the coordinate system. + + Returns: + list: The converted coordinates. + + Raises: + AssertionError: If the maximum coordinate value is greater than 1 or the minimum coordinate value is less than 0. + """ + # Iterate over the outer list + for i in range(len(coordinates)): + # Iterate over each coordinate set in the inner list + for j in range(len(coordinates[i])): + if geo_dict["crs"] == "EPSG:4326": + # Convert the coordinates for the EPSG:4326 + coordinates[i][j] = [ + round( + (coordinates[i][j][0] - geo_dict["left"]) / geo_dict["width"], 6 + ), + round( + (geo_dict["top"] - coordinates[i][j][1]) / geo_dict["height"], 6 + ), + ] + else: + # Convert the coordinates for not EPSG:4326 + coordinates[i][j] = [ + round( + (coordinates[i][j][0] - geo_dict["bottom"]) + / geo_dict["height"], + 6, + ), + round( + (geo_dict["right"] - coordinates[i][j][1]) / geo_dict["width"], + 6, + ), + ] + + coordinates = check_and_clamp(coordinates) + + # Make sure that the coordinates are within the expected range + assert ( + max(flatten_list(coordinates)) <= 1 + ), "The maximum coordinate value is greater than 1" + assert ( + min(flatten_list(coordinates)) >= 0 + ), "The minimum coordinate value is less than 0" + + return coordinates + + + + + + +def write_yolo_file(iwp, folder, output_path, class_index=0): + """ + Writes YOLO label file based on the given image with path and class index. + + Args: + iwp (str): The image with path. + output_path(path) : output path for the yolo label file + class_index (int, optional): The class index for the YOLO label. Defaults to 0. + + Returns: + None + """ + + # Get the GeoJSON filename with path from the chip filename with path + lwp = iwp.replace(".tif", ".geojson").replace("chips", "labels") + + # Create the YOLO label filename with path from the chip filename with path + ywp = os.path.join(output_path,'labels',folder, os.path.basename(iwp).replace(".tif", ".txt")) + # Create the YOLO label folder if it does not exist + os.makedirs(os.path.dirname(ywp), exist_ok=True) + + # Remove the YOLO label file if it already exists + if os.path.exists(ywp): + os.remove(ywp) + + # Fetch the chip's Exif data + geo_dict = get_geo_data(iwp) + + # Open the GeoJSON file + with open(lwp, "r") as file: + data = json.load(file) + + # Initialize the polygon count + polygon_count = 0 + + # Navigate through the GeoJSON structure + for feature in data["features"]: + if feature["geometry"]["type"] == "Polygon": + # Increment the polygon count + polygon_count += 1 + + # Get the coordinates of the polygon + coordinates = feature["geometry"]["coordinates"] + + # Convert the coordinates + new_coordinates = flatten_list(convert_coordinates(coordinates, geo_dict)) + new_coordinate_str = " ".join(map(str, flatten_list(new_coordinates))) + + # Write the converted coordinates to a file + with open(ywp, "a+") as file: + # Move the file pointer to the start of the file to check its contents. + file.seek(0) # Go to the beginning of the file + first_character = file.read( + 1 + ) # Read the first character to determine if the file is empty + + # If the first character does not exist, the file is empty + if not first_character: + # Write the first string without a new line before it + file.write(f"{class_index} " + new_coordinate_str) + + else: + # The file is not empty, write the new string on a new line + file.write(f"\n{class_index} " + new_coordinate_str) + + if polygon_count == 0: + # Open the file in write mode, which creates a new file if it doesn't exist + with open(ywp, "w") as file: + pass # No need to write anything, just creating the file + + +def convert_tif_to_jpg(cwp, folder, output_path, quality_level=100): + """ + Converts a TIFF image file to JPEG format. + + Parameters: + cwp (str): The path to the TIFF image file. + folder (str): The folder name (train, val, or test). + output_path (str): The path to the output YOLO data folders. + quality_level (int, optional): The quality level of the JPEG image (default is 100). + + Returns: + str: The output path of the JPEG image file. + """ + # Open the tif image file + with Image.open(cwp) as img: + # Convert the image to RGB and save it as a JPEG + rgb_img = img.convert("RGB") + + # Define the output path with .jpg extension + jwp = os.path.join( + os.path.join(output_path, "images", folder), + cwp.split("/")[-1].replace(".tif", ".jpg"), + ) + + # Create the output folder if it does not exist + os.makedirs(os.path.dirname(jwp), exist_ok=True) + + # Save the image at quality level ql + rgb_img.save(jwp, "JPEG", quality=quality_level) + + # Print the output path + return f"Writing: {jwp}" diff --git a/hot_fair_utilities/preprocessing/yolo_v8_v2/yolo_format.py b/hot_fair_utilities/preprocessing/yolo_v8_v2/yolo_format.py new file mode 100644 index 00000000..a7191a46 --- /dev/null +++ b/hot_fair_utilities/preprocessing/yolo_v8_v2/yolo_format.py @@ -0,0 +1,181 @@ +# Standard library imports +import glob +import os + +# Third party imports +import numpy as np +import yaml +from tqdm import tqdm +import shutil +from .utils import convert_tif_to_jpg, write_yolo_file + + +def yolo_format( + input_path="preprocessed/*", + output_path="ramp_data_yolo", + seed=42, + train_split=0.7, + val_split=0.15, + test_split=0.15, +): + """ + Preprocess data for YOLO model training. + + Args: + input_path (str, optional): The path to the input data folders. Defaults to "preprocessed/*". + output_path (str, optional): The path to the output YOLO data folders. Defaults to "ramp_data_yolo". + seed (int, optional): The random seed for data splitting. Defaults to 42. + train_split (float, optional): The percentage of data to be used for training. Defaults to 0.7. + val_split (float, optional): The percentage of data to be used for validation. Defaults to 0.15. + test_split (float, optional): The percentage of data to be used for testing. Defaults to 0.15. + + Returns: + None + """ + # Verify the sum of the splits + assert ( + train_split + val_split + test_split == 1 + ), "The sum of the splits must be equal to 1" + + print(f"Train-val-test split: {train_split}-{val_split}-{test_split}") + + # Set the random seed + np.random.seed(seed) + + # Find the files + cwps, lwps, base_folders = find_files(input_path) + + # Shuffle indices + shuffled_indices = np.random.permutation(len(cwps)) + + # Calculate split indices + train_end = int(len(cwps) * train_split) + val_end = train_end + int(len(cwps) * val_split) + + # Split the indices into training, validation, and testing + train_indices = shuffled_indices[:train_end] + val_indices = shuffled_indices[train_end:val_end] + test_indices = shuffled_indices[val_end:] + + # Create train, val, and test arrays + train_cwps = [cwps[i] for i in train_indices] + val_cwps = [cwps[i] for i in val_indices] + test_cwps = [cwps[i] for i in test_indices] + + # Output the results to verify + print(f"\nTrain array size: {len(train_cwps)}") + print(f"Validation array size: {len(val_cwps)}") + print(f"Test array size: {len(test_cwps)}\n") + + # Check if the YOLO folder exists, if not create labels, images, and folders + if os.path.exists(output_path): + shutil.rmtree(output_path) + + os.makedirs(output_path) + + # Write the YOLO label files for the training set + print("Generating training labels") + for train_cwp in tqdm(train_cwps): + write_yolo_file(train_cwp, "train", output_path) + + # Write the YOLO label files for the validation set + print("Generating validation labels") + for val_cwp in tqdm(val_cwps): + write_yolo_file(val_cwp, "val", output_path) + + # Write the YOLO label files for the test set + print("Generating test labels") + for test_cwp in tqdm(test_cwps): + write_yolo_file(test_cwp, "test", output_path) + + # Convert the chip files to JPEG format + print("Generating training images") + for train_cwp in tqdm(train_cwps): + convert_tif_to_jpg(train_cwp, "train", output_path) + + print("Generating validation images") + for val_cwp in tqdm(val_cwps): + convert_tif_to_jpg(val_cwp, "val", output_path) + + print("Generating test images") + for test_cwp in tqdm(test_cwps): + convert_tif_to_jpg(test_cwp, "test", output_path) + + attr = { + "path": output_path, + "train": "images/train", + "val": "images/val", + "names": {0: 1}, + } + # os.makedirs(os.path.join(output_path, "yolo"), exist_ok=True) + + YAML_PATH = os.path.join(output_path, "yolo_dataset.yaml") + print(f"Writing the data file with path={YAML_PATH}") + # Write the file + with open(YAML_PATH, "w") as f: + yaml.dump(attr, f) + + + +def find_files(data_folders): + """ + Find chip (.tif) and label (.geojson) files in the specified folders. + + Args: + data_folders (str): The path to the input data folders. + + Returns: + cwps (list): List of chip filenames with path. + lwps (list): List of label filenames with path. + base_folders (list): List of base folder names. + """ + # Find the folders + data_folders = glob.glob(data_folders) + + # Create a list to store chip (.tif), mask (.mask.tif), and label (.geojson) filenames with path + cwps = [] + lwps = [] + + # Create a list to store the base folder names + base_folders = [] + + for folder in data_folders: + # Pattern to match all .tif files in the current folder, including subdirectories + tif_pattern = f"{folder}/chips/*.tif" + + # Find all .tif files in the current 'training*' folder and its subdirectories + found_tif_files = glob.glob(tif_pattern, recursive=True) + + # Filter out .mask.tif files and add the rest to the tif_files list + for file in found_tif_files: + if file.endswith(".tif"): + cwps.append(file) + + # Pattern to match all .geojson files in the current folder, including subdirectories + geojson_pattern = f"{folder}/labels/*.geojson" + + # Find all .geojson files + found_geojson_files = glob.glob(geojson_pattern, recursive=True) + + # Add found .geojson files to the geojson_files list + lwps.extend(found_geojson_files) + + # Sort the lists + cwps.sort() + lwps.sort() + + # Assert that the the number files for each type are the same + assert len(cwps) == len( + lwps + ), f"Number of {len(cwps)} tif files and {len(lwps) }label files do not match" + + # Function to check that the filenames match + for n, cwp in enumerate(cwps): + c = os.path.basename(cwp).replace(".tif", "") + l = os.path.basename(lwps[n]).replace(".geojson", "") + + assert c == l, f"Chip and label filenames do not match: {c} != {l}" + + base_folders.append(cwp.split("/")[1]) + + return cwps, lwps, base_folders diff --git a/hot_fair_utilities/training/__init__.py b/hot_fair_utilities/training/__init__.py index 27449774..8b137891 100644 --- a/hot_fair_utilities/training/__init__.py +++ b/hot_fair_utilities/training/__init__.py @@ -1 +1 @@ -from .train import run_feedback, train + diff --git a/hot_fair_utilities/training/ramp/__init__.py b/hot_fair_utilities/training/ramp/__init__.py new file mode 100644 index 00000000..c1d3fae6 --- /dev/null +++ b/hot_fair_utilities/training/ramp/__init__.py @@ -0,0 +1 @@ +from .train import train, run_feedback diff --git a/hot_fair_utilities/training/cleanup.py b/hot_fair_utilities/training/ramp/cleanup.py similarity index 100% rename from hot_fair_utilities/training/cleanup.py rename to hot_fair_utilities/training/ramp/cleanup.py diff --git a/hot_fair_utilities/training/prepare_data.py b/hot_fair_utilities/training/ramp/prepare_data.py similarity index 100% rename from hot_fair_utilities/training/prepare_data.py rename to hot_fair_utilities/training/ramp/prepare_data.py diff --git a/hot_fair_utilities/training/ramp_config_base.json b/hot_fair_utilities/training/ramp/ramp_config_base.json similarity index 100% rename from hot_fair_utilities/training/ramp_config_base.json rename to hot_fair_utilities/training/ramp/ramp_config_base.json diff --git a/hot_fair_utilities/training/run_training.py b/hot_fair_utilities/training/ramp/run_training.py similarity index 99% rename from hot_fair_utilities/training/run_training.py rename to hot_fair_utilities/training/ramp/run_training.py index e43fbb93..4c04ac5e 100644 --- a/hot_fair_utilities/training/run_training.py +++ b/hot_fair_utilities/training/ramp/run_training.py @@ -353,7 +353,7 @@ def run_main_train_code(cfg): plt.legend() plt.savefig( - f"{cfg['graph_location']}/training_validation_sparse_categorical_accuracy.png" + f"{cfg['graph_location']}/training_accuracy.png" ) plt.clf() print(f"Graph generated at : {cfg['graph_location']}") diff --git a/hot_fair_utilities/training/train.py b/hot_fair_utilities/training/ramp/train.py similarity index 100% rename from hot_fair_utilities/training/train.py rename to hot_fair_utilities/training/ramp/train.py diff --git a/hot_fair_utilities/training/yolo_v8_v1/__init__.py b/hot_fair_utilities/training/yolo_v8_v1/__init__.py new file mode 100644 index 00000000..935fa4a7 --- /dev/null +++ b/hot_fair_utilities/training/yolo_v8_v1/__init__.py @@ -0,0 +1 @@ +from .train import train diff --git a/train_yolo.py b/hot_fair_utilities/training/yolo_v8_v1/train.py similarity index 50% rename from train_yolo.py rename to hot_fair_utilities/training/yolo_v8_v1/train.py index a67a0d44..71f89654 100644 --- a/train_yolo.py +++ b/hot_fair_utilities/training/yolo_v8_v1/train.py @@ -9,29 +9,32 @@ # Reader imports from hot_fair_utilities.model.yolo import YOLOSegWithPosWeight +from ...utils import compute_iou_chart_from_yolo_results, get_yolo_iou_metrics +# Get environment variables with fallbacks +# ROOT = Path(os.getenv("YOLO_ROOT", Path(__file__).parent.absolute())) +# DATA_ROOT = str(Path(os.getenv("YOLO_DATA_ROOT", ROOT / "yolo-training"))) +# LOGS_ROOT = str(Path(os.getenv("YOLO_LOGS_ROOT", ROOT / "checkpoints"))) -ROOT = Path(__file__).parent.absolute() -DATA_ROOT = str(ROOT / "ramp-training") -LOGS_ROOT = str(ROOT / "checkpoints") - - -# # Different hyperparameters from default in YOLOv8 release models # https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/default.yaml -# - HYPERPARAM_CHANGES = { "imgsz": 256, "mosaic": 0.0, "overlap_mask": False, "cls": 0.5, "degrees": 30.0, + "plots": True, # "optimizer": "SGD", # "weight_decay": 0.001, } - -# torch.set_float32_matmul_precision("high") +# (weights): YOLO is trained from scratch instead of using pretrained weights of COCO dataset, because the data of drone imagery greatly differs from COCO images. +# (imgsz): image size is changed from 640 to 256, to match RAMP size. +# (mosaic): mosaic augmentations removed at all, because they did not make any sense to me to perform them on drone imagery. Mosaic augmentation can furthermore harm the final performance in some cases. +# (mask_overlap): set False, so the masks of building footprints do not overlap with masks of building edges and borders. +# (degrees): set to 30, it is an additional data augmentation that randomly rotates training images up to 30 degrees. Slightly improves the performance. +# (epochs): changed from 100 to 500. This amount better reproduces the numbers reported by ultralytics on COCO dataset for yolov8n trained from scratch (for object detection task). (Ablated.) +# (pc): new implemented option, set to 2.0, which is a weight for positive part of the classification loss, it corresponds to the `pos_weight` in BCEWithLogitsLoss. (Ablated.) def parse_opt(): @@ -40,8 +43,8 @@ def parse_opt(): parser.add_argument( "--data", type=str, - default=os.path.join(DATA_ROOT), - help="Directory containing diractory 'yolo' with dataset.yaml.", + default=DATA_ROOT, # Using the environment variable with fallback + help="Directory containing directory 'yolo' with yolo_dataset.yaml.", ) parser.add_argument( "--weights", @@ -77,21 +80,28 @@ def main(): train(**vars(opt)) -def train(data, weights, gpu, epochs, batch_size, pc, output_path=None): +def train( + data, + weights, + gpu=("cuda" if torch.cuda.is_available() else "cpu"), + epochs=20, + batch_size=8, + pc=2.0, + output_path=None, + dataset_yaml_path=None, +): back = ( "n" if "yolov8n" in weights else "s" if "yolov8s" in weights else "m" if "yolov8m" in weights else "?" ) - data_scn = str(Path(data) / "yolo" / "dataset.yaml") + data_scn = dataset_yaml_path dataset = data_scn.split("/")[-3] kwargs = HYPERPARAM_CHANGES - print(f"Backbone: {back}, Dataset: {dataset}, Epochs: {epochs}") + name = f"yolov8{back}-seg_{dataset}_ep{epochs}_bs{batch_size}" - if output_path: - name = output_path if float(pc) != 0.0: name += f"_pc{pc}" kwargs = {**kwargs, "pc": pc} @@ -99,23 +109,32 @@ def train(data, weights, gpu, epochs, batch_size, pc, output_path=None): else: yolo = ultralytics.YOLO - weights, resume = check4checkpoint(name, weights) + weights, resume = check4checkpoint(name, weights,output_path) model = yolo(weights) model.train( data=data_scn, - project=LOGS_ROOT, + project=os.path.join(output_path,'checkpoints'), name=name, epochs=int(epochs), resume=resume, deterministic=False, + verbose=True, + save_dir= os.path.join(output_path), device=[int(i) for i in gpu.split(",")] if "," in gpu else gpu, **kwargs, ) - return weights + compute_iou_chart_from_yolo_results(results_csv_path=os.path.join(output_path,"checkpoints", name,'results.csv'),results_output_chart_path=os.path.join(output_path,"checkpoints", name,'iou_chart.png')) + + output_model_path=os.path.join(os.path.join(output_path,"checkpoints"), name, "weights", "best.pt") + + iou_model_accuracy=get_yolo_iou_metrics(output_model_path) + + return output_model_path,iou_model_accuracy + -def check4checkpoint(name, weights): - ckpt = os.path.join(LOGS_ROOT, name, "weights", "best.pt") +def check4checkpoint(name, weights,output_path): + ckpt = os.path.join(os.path.join(output_path,'checkpoints'), name, "weights", "last.pt") if os.path.exists(ckpt): print(f"Set weights to {ckpt}") return ckpt, True diff --git a/hot_fair_utilities/training/yolo_v8_v2/__init__.py b/hot_fair_utilities/training/yolo_v8_v2/__init__.py new file mode 100644 index 00000000..935fa4a7 --- /dev/null +++ b/hot_fair_utilities/training/yolo_v8_v2/__init__.py @@ -0,0 +1 @@ +from .train import train diff --git a/hot_fair_utilities/training/yolo_v8_v2/train.py b/hot_fair_utilities/training/yolo_v8_v2/train.py new file mode 100644 index 00000000..8297ca78 --- /dev/null +++ b/hot_fair_utilities/training/yolo_v8_v2/train.py @@ -0,0 +1,105 @@ +# Standard library imports +import argparse +import os +from pathlib import Path + +# Third party imports +import torch +import ultralytics +from ...utils import get_yolo_iou_metrics,compute_iou_chart_from_yolo_results +# Reader imports +from hot_fair_utilities.model.yolo import YOLOSegWithPosWeight + +# ROOT = Path(os.getenv("YOLO_ROOT", Path(__file__).parent.absolute())) +# DATA_ROOT = str(Path(os.getenv("YOLO_DATA_ROOT", ROOT / "yolo-training"))) + + +HYPERPARAM_CHANGES = { + "amp": True, + # lr setup + "optimizer": "auto", + "lr0": 0.00854, + "lrf": 0.01232, + "momentum": 0.95275, + "weight_decay": 0.00058, + "warmup_epochs": 3.82177, + "warmup_momentum": 0.81423, + # loss parameters + "box": 7.48109, + "cls": 0.775, + "dfl": 1.5, + # aug use + "hsv_h": 0.01269, + "hsv_s": 0.68143, + "hsv_v": 0.27, + # aug turn off + "mosaic": 0, + "translate": 0, + "scale": 0, + "shear": 0, + "flipud": 0.5, + "fliplr": 0.255, + "erasing": 0, + "degrees": 15.75, + # Add other parameters as needed + "overlap_mask": False, + "nbs": 64, + "plots": True, + "cache": True, + "val": True, + "save": True, +} + + +def train(data, weights, epochs, batch_size, pc, output_path, dataset_yaml_path,gpu=("cuda" if torch.cuda.is_available() else "cpu"),): + back = ( + "n" + if "yolov8n" in weights + else "s" if "yolov8s" in weights else "m" if "yolov8m" in weights else "?" + ) + data_scn = dataset_yaml_path + dataset = data_scn.split("/")[-3] + kwargs = HYPERPARAM_CHANGES + print(f"Backbone: {back}, Dataset: {dataset}, Epochs: {epochs}") + + name = f"yolov8{back}-seg_{dataset}_ep{epochs}_bs{batch_size}" + + if float(pc) != 0.0: + name += f"_pc{pc}" + kwargs = {**kwargs, "pc": pc} + yolo = YOLOSegWithPosWeight + else: + yolo = ultralytics.YOLO + + weights, resume = check4checkpoint(name, weights,output_path) + model = yolo(weights) + model.train( + data=data_scn, + project=os.path.join(output_path,"checkpoints"), # Using the environment variable with fallback + name=name, + epochs=int(epochs), + resume=resume, + verbose=True, + deterministic=False, + save_dir= os.path.join(output_path), + device=[int(i) for i in gpu.split(",")] if "," in gpu else gpu, + **kwargs, + ) + + # metrics = model.val(save_json=True, plots=True) + # print(model.val()) + compute_iou_chart_from_yolo_results(results_csv_path=os.path.join(output_path,"checkpoints", name,'results.csv'),results_output_chart_path=os.path.join(output_path,"checkpoints", name,'iou_chart.png')) + + output_model_path=os.path.join(os.path.join(output_path,"checkpoints"), name, "weights", "best.pt") + + iou_model_accuracy=get_yolo_iou_metrics(output_model_path) + + return output_model_path,iou_model_accuracy + + +def check4checkpoint(name, weights,output_path): + ckpt = os.path.join(os.path.join(output_path,"checkpoints"), name, "weights", "last.pt") + if os.path.exists(ckpt): + print(f"Set weights to {ckpt}") + return ckpt, True + return weights, False diff --git a/hot_fair_utilities/utils.py b/hot_fair_utilities/utils.py index 5eec88cb..1ce759d4 100644 --- a/hot_fair_utilities/utils.py +++ b/hot_fair_utilities/utils.py @@ -5,12 +5,15 @@ import math import os import re +import ultralytics + import time import urllib.request import zipfile from glob import glob from typing import Tuple - +import pandas as pd +import matplotlib.pyplot as plt # Third party imports # Third-party imports import geopandas @@ -26,7 +29,7 @@ def get_prefix(path: str) -> str: return os.path.splitext(filename)[0] -def get_bounding_box(filename: str) -> Tuple[float, float, float, float]: +def get_bounding_box(filename: str,epsg=3857) -> Tuple[float, float, float, float]: """Get the EPSG:3857 coordinates of bounding box for the OAM image. This function gives the coordinates of lower left and upper right @@ -36,7 +39,7 @@ def get_bounding_box(filename: str) -> Tuple[float, float, float, float]: Returns: A tuple, (x_min, y_min, x_max, y_max), with coordinates in meters. """ - filename = re.sub(r'\.(png|jpeg)$', '', filename) + filename = re.sub(r"\.(png|jpeg)$", "", filename) _, *tile_info = re.split("-", filename) x_tile, y_tile, zoom = map(int, tile_info) @@ -49,7 +52,8 @@ def get_bounding_box(filename: str) -> Tuple[float, float, float, float]: gdf_4326 = geopandas.GeoDataFrame({"geometry": [box_4326]}, crs="EPSG:4326") # Reproject to EPSG:3857 - gdf_3857 = gdf_4326.to_crs("EPSG:3857") + + gdf_3857 = gdf_4326.to_crs(f"EPSG:{epsg}") # Bounding box in EPSG:3857 as a tuple (x_min, y_min, x_max, y_max) box_3857 = gdf_3857.iloc[0, 0].bounds @@ -213,7 +217,7 @@ def tms2img(start: list, end: list, zm_level, base_path, source="maxar"): executor.submit(download_image, url, base_path, source_name) -def fetch_osm_data(payload: json, API_URL="https://raw-data-api0.hotosm.org/v1"): +def fetch_osm_data(payload: json, API_URL="https://api-prod.raw-data.hotosm.org/v1"): """ args : payload : Payload request for API URL @@ -250,3 +254,37 @@ def fetch_osm_data(payload: json, API_URL="https://raw-data-api0.hotosm.org/v1") with zip_ref.open("Export.geojson") as file: my_export_geojson = json.loads(file.read()) return my_export_geojson + + +import pandas as pd +import matplotlib.pyplot as plt + + +def compute_iou_chart_from_yolo_results(results_csv_path,results_output_chart_path): + + data = pd.read_csv(results_csv_path) + + + data['IoU(M)'] = 1 / ( + 1 / data['metrics/precision(M)'] + 1 / data['metrics/recall(M)'] - 1 + ) + chart = data.plot(x='epoch',y='IoU(M)',title='IoU (Mask) per Epoch',xticks=data['epoch'].astype(int)).get_figure() + + chart.savefig(results_output_chart_path) + return results_output_chart_path + + +def get_yolo_iou_metrics(model_path): + + model_val = ultralytics.YOLO(model_path) + model_val_metrics = ( + model_val.val().results_dict + ) ### B and M denotes bounding box and mask respectively + # print(metrics) + iou_accuracy = 1 / ( + 1 / model_val_metrics["metrics/precision(M)"] + + 1 / model_val_metrics["metrics/recall(M)"] + - 1 + ) # ref here https://github.com/ultralytics/ultralytics/issues/9984#issuecomment-2422551315 + final_accuracy = iou_accuracy * 100 + return final_accuracy \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dc4c1058..3cc6a0ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,13 @@ requires = ["setuptools>=61.0.0", "wheel"] build-backend = "setuptools.build_meta" +[tool.setuptools] +packages = ["hot_fair_utilities"] + + [project] name = "hot-fair-utilities" -version = "1.3.0" +version = "3.0.0" description = "Utilities for AI - Assisted Mapping fAIr" readme = "README.md" authors = [{ name = "Hot Tech Team", email = "sysadmin@hotosm.org" }] @@ -18,10 +22,12 @@ keywords = [ "postprocessing", "stitching","training" ] dependencies = [ - "shapely==1.8.0", "GDAL", "numpy", - "Pillow==9.0.1", "geopandas<=0.14.5","pandas==1.5.3", - "rasterio", "mercantile==1.2.1", "tqdm==4.62.3", - "rtree", "opencv-python==4.5.5.64","opencv-python-headless<=4.7.0.68","ramp-fair==0.1.2" + "shapely==1.8.0","GDAL", "numpy", + "Pillow==9.1.0", "geopandas==0.14.4","pandas==2.2.3", + "rasterio", "mercantile==1.2.1", "tqdm==4.67.0", + "rtree","opencv-python-headless<=4.10.0.84", + "torch==2.5.1", "torchvision==0.20.1", "torchaudio==2.5.1","ultralytics==8.3.26", + "ramp-fair==0.1.2" ] requires-python = ">=3.7" diff --git a/requirements.txt b/requirements.txt index 7a594796..e629c901 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,6 @@ black isort build twine -torch==1.12.1 -torchvision==0.13.1 -ultralytics==8.1.6 +# torch==1.12.1 +# torchvision==0.13.1 +# ultralytics==8.1.6 diff --git a/test_app.py b/test_ramp.py similarity index 96% rename from test_app.py rename to test_ramp.py index 0b2a3385..465e9034 100644 --- a/test_app.py +++ b/test_ramp.py @@ -19,7 +19,7 @@ start_time = time.time() # Third party imports # %% -import ramp.utils +# import ramp.utils # Reader imports import hot_fair_utilities @@ -43,7 +43,7 @@ # Reader imports # %% -from hot_fair_utilities import train +from hot_fair_utilities.training.ramp import train # %% train_output = f"{base_path}/train" diff --git a/test_yolo.py b/test_yolo_v1.py similarity index 80% rename from test_yolo.py rename to test_yolo_v1.py index 5a23cd5f..b24f044f 100644 --- a/test_yolo.py +++ b/test_yolo_v1.py @@ -6,16 +6,13 @@ print(os.getcwd()) os.environ.update(os.environ) os.environ["RAMP_HOME"] = os.getcwd() -print(os.environ["RAMP_HOME"]) -# Third party imports -import tensorflow as tf # Reader imports from hot_fair_utilities import polygonize, predict, preprocess -from hot_fair_utilities.preprocessing.yolo_format import yolo_format -from train_yolo import train as train_yolo +from hot_fair_utilities.preprocessing.yolo_v8_v1.yolo_format import yolo_format +from hot_fair_utilities.training.yolo_v8_v1.train import train as train_yolo warnings.simplefilter(action="ignore", category=FutureWarning) @@ -32,10 +29,6 @@ def __exit__(self, type, value, traceback): print(f"{self.name} took {round(time.perf_counter() - self.start, 2)} seconds") -# os.environ.update(os.environ) -# os.environ["RAMP_HOME"] = os.getcwd() -# print(os.environ["RAMP_HOME"]) - start_time = time.time() base_path = f"{os.getcwd()}/ramp-data/sample_2" @@ -51,7 +44,7 @@ def __exit__(self, type, value, traceback): multimasks=True, # new arg ) -yolo_data_dir = f"{base_path}/yolo" +yolo_data_dir = f"{base_path}/yolo_v1" with print_time("yolo conversion"): yolo_format( preprocessed_dirs=preprocess_output, @@ -60,14 +53,18 @@ def __exit__(self, type, value, traceback): p_val=0.05, ) -output_model_path = train_yolo( +output_model_path,output_model_iou_accuracy = train_yolo( data=f"{base_path}", - weights=f"{os.getcwd()}/ylov8_seg_alb_best.pt", - gpu="cpu", + weights=f"{os.getcwd()}/yolov8s_v1-seg-best.pt", + # gpu="cpu", epochs=2, batch_size=16, pc=2.0, + output_path=yolo_data_dir, + dataset_yaml_path=os.path.join(yolo_data_dir,'yolo_dataset.yaml') + ) +print(output_model_iou_accuracy) prediction_output = f"{base_path}/prediction/output" # model_path = f"{output_path}/weights/best.pt" diff --git a/test_yolo_v2.py b/test_yolo_v2.py new file mode 100644 index 00000000..555bcedd --- /dev/null +++ b/test_yolo_v2.py @@ -0,0 +1,83 @@ +# Standard library imports +import os +import time +import warnings +import ultralytics + +os.environ.update(os.environ) +os.environ["RAMP_HOME"] = os.getcwd() + + +# Reader imports +from hot_fair_utilities import polygonize, predict, preprocess +from hot_fair_utilities.preprocessing.yolo_v8_v2.yolo_format import yolo_format +from hot_fair_utilities.training.yolo_v8_v2.train import train as train_yolo + +warnings.simplefilter(action="ignore", category=FutureWarning) + + +class print_time: + def __init__(self, name): + self.name = name + + def __enter__(self): + self.start = time.perf_counter() + return self + + def __exit__(self, type, value, traceback): + print(f"{self.name} took {round(time.perf_counter() - self.start, 2)} seconds") + + +start_time = time.time() +base_path = f"{os.getcwd()}/ramp-data/sample_2" + +model_input_image_path = f"{base_path}/input" +preprocess_output = f"{base_path}/preprocessed" +with print_time("preprocessing"): + preprocess( + input_path=model_input_image_path, + output_path=preprocess_output, + rasterize=True, + rasterize_options=["binary"], + georeference_images=True, + multimasks=False, + epsg=4326 + ) + +yolo_data_dir = f"{base_path}/yolo_v2" +with print_time("yolo conversion"): + yolo_format( + input_path=preprocess_output, + output_path=yolo_data_dir, + ) + +output_model_path,output_model_iou_accuracy = train_yolo( + data=f"{base_path}", + weights=f"{os.getcwd()}/yolov8s_v2-seg.pt", + # gpu="cpu", + epochs=2, + batch_size=16, + pc=2.0, + output_path=yolo_data_dir, + dataset_yaml_path=os.path.join(yolo_data_dir,'yolo_dataset.yaml') +) +print(output_model_iou_accuracy) + +prediction_output = f"{base_path}/prediction/output" +# model_path = f"{output_path}/weights/best.pt" +with print_time("inference"): + predict( + checkpoint_path=output_model_path, + input_path=f"{base_path}/prediction/input", + prediction_path=prediction_output, + ) + +geojson_output = f"{prediction_output}/prediction.geojson" +with print_time("polygonization"): + polygonize( + input_path=prediction_output, + output_path=geojson_output, + remove_inputs=False, + ) + +print(f"\n Total Process Completed in : {time.time()-start_time} sec") diff --git a/ylov8_seg_alb_best.pt b/yolov8s_v1-seg-best.pt similarity index 100% rename from ylov8_seg_alb_best.pt rename to yolov8s_v1-seg-best.pt diff --git a/yolov8s_v2-seg.pt b/yolov8s_v2-seg.pt new file mode 100644 index 00000000..133ff3a2 Binary files /dev/null and b/yolov8s_v2-seg.pt differ