import os, sys
from pathlib import Path
import rasterio as rio
import rasterio.plot as rioplot
import rasterio.windows as riowindow
from rasterio.windows import Window
import matplotlib.pyplot as plt
from shapely.geometry import box, Polygon
import numpy as np
import geopandas as gpd
from itertools import product
import random
1, '..')
sys.path.insert(from src.colors import *
Converting the annotations to computer vision datasets
This page contains the instructions to access the satellite data from Copernicus Data Space Ecosystem, the annotations from Zenodo and creating computer vision datasets from them. Dataset conversions are done with geo2ml.
Accessing the annotations
The manually annotated vessel data are stored in 10.5281/zenodo.10046341 as GeoPackage files. Each gpkg file corresponds to a single tile location, and each layer in a gpkg corresponds to a specific date.
The following mosaics were used as training and validation data.
Location | Product name | Number of annotations |
---|---|---|
Archipelago sea | S2A_MSIL1C_20220515T100031_N0400_R122_T34VEM_20220515T120450 | 183 |
Archipelago sea | S2B_MSIL1C_20220619T100029_N0400_R122_T34VEM_20220619T104419 | 519 |
Archipelago sea | S2A_MSIL1C_20220721T095041_N0400_R079_T34VEM_20220721T115325 | 1518 |
Archipelago sea | S2A_MSIL1C_20220813T095601_N0400_R122_T34VEM_20220813T120233 | 1371 |
Gulf of Finland | S2B_MSIL1C_20220606T095029_N0400_R079_T35VLG_20220606T105944 | 277 |
Gulf of Finland | S2B_MSIL1C_20220626T095039_N0400_R079_T35VLG_20220626T104321 | 1205 |
Gulf of Finland | S2B_MSIL1C_20220703T094039_N0400_R036_T35VLG_20220703T103953 | 746 |
Gulf of Finland | S2A_MSIL1C_20220721T095041_N0400_R079_T35VLG_20220721T115325 | 971 |
Bothnian Bay | S2A_MSIL1C_20220627T100611_N0400_R022_T34WFT_20220627T134958 | 122 |
Bothnian Bay | S2B_MSIL1C_20220712T100559_N0400_R022_T34WFT_20220712T121613 | 162 |
Bothnian Bay | S2B_MSIL1C_20220828T095549_N0400_R122_T34WFT_20220828T104748 | 98 |
The following mosaics were used as test data:
Location | Product name | Number of annotations |
---|---|---|
Bothnian sea | S2B_MSIL1C_20210714T100029_N0301_R122_T34VEN_20210714T121056 | 450 |
Bothnian sea | S2B_MSIL1C_20220619T100029_N0400_R122_T34VEN_20220619T104419 | 66 |
Bothnian sea | S2A_MSIL1C_20220624T100041_N0400_R122_T34VEN_20220624T120211 | 424 |
Bothnian sea | S2A_MSIL1C_20220813T095601_N0400_R122_T34VEN_20220813T120233 | 399 |
Kvarken | S2B_MSIL1C_20210714T100029_N0301_R122_T34VEN_20210714T121056 | 83 |
Kvarken | S2B_MSIL1C_20220712T100559_N0400_R022_T34VER_20220712T121613 | 184 |
Kvarken | S2A_MSIL1C_20220826T100611_N0400_R022_T34VER_20220826T135136 | 88 |
Accessing the satellite data
The easiest way to access Sentinel-2 products is with Copernicus Data Space Ecosystem (CDSE). After registering, the data can be accessed via Copernicus Browser, or through different APIs (OData, STAC).
There are two scripts that can be used to download the data through OData API. In order to use them with --creds_file
argument, create a following file somewhere safe to store the credentials:
{
"grant_type": "password",
"username": "<your_use_email>",
"password": "<password>",
"client_id": "cdse-public"
}
scripts/cdse_odata.py can be used to download the full Sentinel-2 product, whereas scripts/cdse_odata_tci.py downloads only the L1C-TCI image, provided it is available. Both scripts can be used like this:
python scripts/cdse_odata.py <S2_product_name>.SAFE --creds_file <path_to_creds_file> --target_path <where_to_download>
There are some cases where the L1C-TCI is not available. It can be constructed from the full product directory like this:
import glob
= 3558
SATURATION_VALUE
def make_tci_mosaic(input_safe:str, outfile:str):
"""Create a L1C-TCI mosaic from a .SAFE directory found from `input_safe`
and save it to `outfile`
"""
= glob.glob(os.path.join(input, 'MTD_MSIL1C.xml'))[0]
xmlroot
with rio.open(xmlroot, 'r') as src:
= src.subdatasets[0]
data_10m
# Derive offset_value depending on the processing baseline
= int(input.split('_')[-4][2:])
baseline_ver if baseline_ver >= 400: RADIO_ADD_OFFSET = -1000
else: RADIO_ADD_OFFSET = 0
with rio.open(data_10m, 'r') as src:
= (src.read(1) + RADIO_ADD_OFFSET)/SATURATION_VALUE
B04 = (src.read(2) + RADIO_ADD_OFFSET)/SATURATION_VALUE
B03 = (src.read(3) + RADIO_ADD_OFFSET)/SATURATION_VALUE
B02 = src.profile
prof
= np.clip(255*B04, 0, 255).astype(np.uint8)
r = np.clip(255*B03, 0, 255).astype(np.uint8)
g = np.clip(255*B02, 0, 255).astype(np.uint8)
b
prof.update(=3,
count='uint8',
dtype='GTiff',
driver='lzw',
compress=2,
predictor='YES'
BIGTIFF
)
print('Writing data')
with rio.open(outfile, 'w', **prof) as dst:
dst.write(np.stack([r, g, b]))
In this example, we use the product S2A_MSIL1C_20220721T095041_N0400_R079_T34VEM_20220721T115325
to show how the data can be constructed. After registration to CDSE, download the data from either via Copernicus Browser or with
python scripts/cdse_odata_tci.py S2A_MSIL1C_20220721T095041_N0400_R079_T34VEM_20220721T115325.SAFE --creds_file <path_to_creds> --target_path <path_to_data>
Visualizing the data
= Path('../l1c_tci/mosaics/S2A_MSIL1C_20220721T095041_N0400_R079_T34VEM_20220721T115325_TCI_10m.tif')
data_path = gpd.read_file('../dataset/34VEM.gpkg', layer='20220721') annotations
= 1.5 # L1C-TCI are dark in the Finnish coast so brighten them
bright = Window(8000,5000,640,640)
win
= plt.subplots(1,1, dpi=300)
fig, ax 'off')
ax.axis(with rio.open(data_path) as src:
= src.read(window=win)
im = src.window_transform(win)
win_transform = riowindow.bounds(win, src.transform)
win_bounds *bright).astype(int), transform=win_transform, ax=ax, vmin=100, vmax=255)
rioplot.show((im
*win_bounds)).plot(ax=ax, facecolor='none', edgecolor='red')
annotations.clip(box( plt.show()
As the annotations are hand-drawn with QGis, they might not always be pixel-perfect. Typically there is a slight buffer around each side, and it can be tightened with the following function, if needed:
def edge_coords(geom):
"Tighten the bounds to S2 pixel edges"
= geom.bounds
xmin, ymin, xmax, ymax # xmin rounds up
= np.ceil(xmin/10) * 10
edge_xmin # ymin rounds up
= np.ceil(ymin/10) * 10
edge_ymin # xmax rounds down
= np.floor(xmax/10) * 10
edge_xmax # ymax rounds down
= np.floor(ymax/10) * 10
edge_ymax return box(edge_xmin, edge_ymin, edge_xmax, edge_ymax)
= annotations.copy()
annotations_tight 'geometry'] = annotations_tight.geometry.apply(edge_coords)
annotations_tight[
= plt.subplots(1,2, figsize=(8,4), dpi=300)
fig, axs = Window(7000,6000,320,320)
win
for a in axs: a.axis('off')
with rio.open(data_path) as src:
= src.read(window=win)
im = src.window_transform(win)
win_transform = riowindow.bounds(win, src.transform)
win_bounds *bright).astype(int), transform=win_transform, ax=axs[0])
rioplot.show((im*bright).astype(int), transform=win_transform, ax=axs[1])
rioplot.show((im
*win_bounds)).plot(ax=axs[0], facecolor='none', edgecolor='red')
annotations.clip(box(*win_bounds)).plot(ax=axs[1], facecolor='none', edgecolor='red')
annotations_tight.clip(box(0].set_title('Original bounding boxes')
axs[1].set_title('Tightened boxes')
axs[
plt.tight_layout() plt.show()
Also, the annotations are done with different color adjustments easier for human experts to interpret, so some targets are extremely difficult to see. As computer vision models interpret the image differently, it is not that much of a problem for them.
Computer vision dataset creation
There are a couple of ways to convert the geodata to YOLO format dataset with geo2ml
. The scripts create_yolo_dataset
and create_coco_dataset
are straightforward utility functions that
- Split the raster data into smaller chips
- Split the corresponding vector data into smaller files, each corresponding to a single chip
- Convert the geo-annotations into specific dataset format
If there is a need to customize (e.g. dataset splits) the workflow, then scripts/make_yolo_data.py shows an example on how to do that. In this script, each mosaic are split into a grid, and the diagonal cells are used as validation data. Each of the train mosaics are split in this way, resulting into 80-20 spatial train-val-split.
def make_fold_grid(poly, num_edge, crs) -> gpd.GeoDataFrame:
= poly.bounds
xmin, ymin, xmax, ymax = (xmax-xmin)//num_edge
dx = (ymax-ymin)//num_edge
dy = []
polys = []
folds for x, y in product(range(0, num_edge), range(0, num_edge)):
= xmin+x*dx
xm = ymin+y*dy
ym *(xm, ym, xm+dx, ym+dy)))
polys.append(box(f'fold_{(x-y)%num_edge + 1}')
folds.append(return gpd.GeoDataFrame(data={'fold': folds, 'geometry': polys}, crs=crs)
with rio.open(data_path) as src:
= src.bounds
bounds = src.crs
crs
= make_fold_grid(box(*bounds), 5, crs)
grid 'val'] = grid.fold.apply(lambda row: 1 if row == 'fold_1' else 0)
grid[='val', edgecolor='white')
grid.plot(column plt.show()
YOLO conversion
from geo2ml.scripts.data import create_yolo_dataset
/home/mayrajeo/miniconda3/envs/ml-env/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
The following creates a yolo dataset to yolo_outpath
, along with the template for dataset.yaml
file.
= Path('example_data/yolo') # Outpath for the dataset
yolo_outpath =data_path, polygon_path='../dataset/34VEM.gpkg', target_column='id',
create_yolo_dataset(raster_path=yolo_outpath, save_grid=False, gridsize_x=320, gridsize_y=320,
outpath='box', min_bbox_area=0, allow_partial_data=True, gpkg_layer='20220721') ann_format
1225it [01:05, 18.71it/s]
1225it [00:08, 138.54it/s]
/home/mayrajeo/miniconda3/envs/ml-env/lib/python3.11/site-packages/pyogrio/geopandas.py:265: UserWarning: More than one layer found in '34VEM.gpkg': '20220515' (default), '20220619', '20220721', '20220813'. Specify layer parameter to avoid this warning.
result = read_func(
395it [00:22, 17.88it/s]
COCO conversion
from geo2ml.scripts.data import create_coco_dataset
The following creates a coco dataset to coco_outpath
, with the complete json
file containing all the information about the dataset.
= Path('example_data/coco') # Outpath for the dataset
coco_outpath =data_path, polygon_path='../dataset/34VEM.gpkg', target_column='id',
create_coco_dataset(raster_path=coco_outpath, save_grid=False, gridsize_x=320, gridsize_y=320, dataset_name='34VEN_20220721',
outpath='box', min_bbox_area=0, allow_partial_data=True, gpkg_layer='20220721') ann_format
1225it [00:56, 21.56it/s]
1225it [00:08, 141.27it/s]
/home/mayrajeo/miniconda3/envs/ml-env/lib/python3.11/site-packages/pyogrio/geopandas.py:265: UserWarning: More than one layer found in '34VEM.gpkg': '20220515' (default), '20220619', '20220721', '20220813'. Specify layer parameter to avoid this warning.
result = read_func(
395it [00:13, 29.40it/s]
Combining with Segment-Anything
These annotations can be fed into a Segment Anything or SAM2 as prompts, and thus get more accurate instance masks instead of bounding boxes for the vessels. Here’s an example on how to do it. First import required libraries and config SAM2
import torch
from sam2.build_sam import build_sam2
from sam2.sam2_image_predictor import SAM2ImagePredictor
from geo2ml.data.coordinates import gdf_to_px, georegister_px_df
import cv2
def show_mask(mask, ax, random_color=False, borders = True):
if random_color:
= np.concatenate([np.random.random(3), np.array([0.6])], axis=0)
color else:
= np.array([30/255, 144/255, 255/255, 0.6])
color = mask.shape[-2:]
h, w = mask.astype(np.uint8)
mask = mask.reshape(h, w, 1) * color.reshape(1, 1, -1)
mask_image if borders:
import cv2
= cv2.findContours(mask,cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
contours, _ # Try to smooth contours
= [cv2.approxPolyDP(contour, epsilon=0.01, closed=True) for contour in contours]
contours = cv2.drawContours(mask_image, contours, -1, (1, 1, 1, 0.5), thickness=2)
mask_image
ax.imshow(mask_image)
def show_box(box, ax):
= box[0], box[1]
x0, y0 = box[2] - box[0], box[3] - box[1]
w, h ='green', facecolor=(0, 0, 0, 0), lw=2))
ax.add_patch(plt.Rectangle((x0, y0), w, h, edgecolor
= Path('../../../sam2/')
sam2_path = sam2_path/'checkpoints'
checkpoint_path
= checkpoint_path/'sam2.1_hiera_large.pt'
checkpoint = 'configs/sam2.1/sam2.1_hiera_l.yaml'
model_cfg = SAM2ImagePredictor(build_sam2(model_cfg, checkpoint)) predictor
Here, we can use the chipped image and geojson data for prompting. First select only the images with annotations. This can be done by matching the yolo label files with the images.
= [yolo_outpath/'images'/f'{i.replace("txt", "tif")}' for i in os.listdir(yolo_outpath/'labels')]
images_with_anns = [yolo_outpath/'vectors'/f'{i.replace("txt", "geojson")}' for i in os.listdir(yolo_outpath/'labels')] valid_anns
Then we can use gdf_to_px
from geo2ml
to convert the georeferenced data to pixel coordinates:
= 55
im_ix = gpd.read_file(valid_anns[im_ix])
ex_gdf = gdf_to_px(ex_gdf.copy(), images_with_anns[im_ix])
ex_px_gdf ex_px_gdf.head()
id | orig_area | geometry | image_fname | |
---|---|---|---|---|
0 | boat | 7484.336062 | POLYGON ((35.551 91.055, 41.085 91.055, 41.085... | R12C17.tif |
1 | boat | 2811.941603 | POLYGON ((169.696 77.001, 174.999 77.001, 174.... | R12C17.tif |
2 | boat | 1786.034742 | POLYGON ((91.346 61.083, 95.649 61.083, 95.649... | R12C17.tif |
3 | boat | 13383.448166 | POLYGON ((51.844 44.79, 67.675 44.79, 67.675 5... | R12C17.tif |
4 | boat | 3146.823117 | POLYGON ((49.846 32.34, 55.533 32.34, 55.533 3... | R12C17.tif |
Now we can prompt the SAM2ImagePredictor
with bounding boxes, found on ex_px_gdf.geometry
. First put the boxes into a numpy array
= np.array([g.bounds for g in ex_px_gdf.geometry]) input_box
Then set image for the predictor
.
with rio.open(images_with_anns[im_ix]) as src:
= src.read()
im = np.moveaxis(im, (0,1,2), (2,0,1))
im predictor.set_image(im)
And get the masks from predictor
.
= predictor.predict(
masks, scores, _ =None,
point_coords=None,
point_labels=input_box,
box=False
multimask_output )
Plot the masks along with the bounding boxes.
= plt.subplots(1,2, figsize=(10,4), dpi=200)
f, a 0].axis('off')
a[0].imshow((im*bright).astype(int))
a[for box in input_box: show_box(box, a[0])
1].axis('off')
a[1].imshow((im*bright).astype(int))
a[for mask in masks: show_mask(mask.squeeze(0), a[1])
Next step is to convert the data into polygons and georeference them. rasterio.features.shapes
can create proper polygons that cover all the pixels in the mask and follow the pixel edges.
from pycococreatortools.pycococreatortools import binary_mask_to_polygon
= []
polys
for mask in masks:
= binary_mask_to_polygon(mask[0])
bmask = [(bmask[0][i],
xy_coords 0][i+1])
bmask[for i in range(0,len(bmask[0]),2)]
-1])
xy_coords.append(xy_coords[
polys.append(Polygon(xy_coords))
= ex_px_gdf.copy()
poly_px_gdf 'geometry'] = polys poly_px_gdf[
from rasterio.features import shapes
list(shapes(masks[0][0].astype(np.int8)))[0][0])['coordinates'][0]) Polygon((
= []
polys
for mask in masks:
= list(shapes(mask[0].astype(np.int8)))
shape_list list(shapes(mask[0].astype(np.int8)))[0][0])['coordinates'][0]))
polys.append(Polygon((
= ex_px_gdf.copy()
poly_px_gdf 'geometry'] = polys poly_px_gdf[
Now only thing that is needed is to georeference these polygons, which can be done with georegister_px_df
from geo2ml
:
= plt.subplots(1,1, dpi=200)
f, a 'off')
a.axis(with rio.open(images_with_anns[im_ix]) as src:
= src.transform
tfm = src.read()
im *bright).astype(int), transform=tfm, ax=a)
rioplot.show((im
= georegister_px_df(poly_px_gdf.copy(), images_with_anns[im_ix])
poly_gdf ='none', ax=a, edgecolor='r', linewidth=.5)
ex_gdf.plot(facecolor='none', ax=a, edgecolor='g', linewidth=.5) poly_gdf.plot(facecolor
Each file can then be saved to a file. You can loop through all files to create the masks for the dataset. Masks can even further be improved by clipping the masks to be within the bounding boxes. Most likely results will improve by using tightened bounding boxes.
f'example_data/sam2/vectors/{valid_anns[im_ix].name}') poly_gdf.to_file(