Source code for aitlas.datasets.breizhcrops

"""
BreizhCrops - a crop type classification dataset

.. note:: Adapted from: https://github.com/dl4sits/BreizhCrops ;
    Original implementation of BreizhCrops dataset: https://github.com/dl4sits/BreizhCrops/blob/master/breizhcrops/datasets/breizhcrops.py

"""
import logging
import os
import tarfile
import urllib
import zipfile

import h5py
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from aitlas.datasets.crops_classification import CropsDataset
from tqdm import tqdm

from ..base import BaseDataset
from .schemas import BreizhCropsSchema
from .urls import (
    CLASSMAPPINGURL,
    CODESURL,
    FILESIZES,
    RAW_CSV_URL,
    H5_URLs,
    INDEX_FILE_URLs,
    SHP_URLs,
)


logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


[docs]class DownloadProgressBar(tqdm):
[docs] def update_to(self, b=1, bsize=1, tsize=None): if tsize is not None: self.total = tsize self.update(b * bsize - self.n)
[docs]def download_file(url, output_path, overwrite=False): if url is None: raise ValueError("download_file: provided url is None!") if not os.path.exists(output_path) or overwrite: with DownloadProgressBar( unit="B", unit_scale=True, miniters=1, desc=url.split("/")[-1] ) as t: urllib.request.urlretrieve( url, filename=output_path, reporthook=t.update_to ) else: logging.info( f"file exists in {output_path}. specify `overwrite=True` if intended" )
[docs]def unzip(zipfile_path, target_dir): with zipfile.ZipFile(zipfile_path) as zip: for zip_info in zip.infolist(): if zip_info.filename[-1] == "/": continue zip_info.filename = os.path.basename(zip_info.filename) zip.extract(zip_info, target_dir)
[docs]def untar(filepath): dirname = os.path.dirname(filepath) with tarfile.open(filepath, "r:gz") as tar: for member in tar.getmembers(): if member.isreg(): # skip if the TarInfo is not files member.name = os.path.basename( member.name ) # remove the path by reset it tar.extract(member, dirname) # extract
PADDING_VALUE = -1 BANDS = { "L1C": [ "B1", "B10", "B11", "B12", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B9", "QA10", "QA20", "QA60", "doa", "label", "id", ], "L2A": [ "doa", "id", "code_cultu", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B11", "B12", "CLD", "EDG", "SAT", ], } SELECTED_BANDS = { "L1C": [ "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B9", "B10", "B11", "B12", "QA10", "QA20", "QA60", "doa", ], "L2A": [ "doa", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B11", "B12", "CLD", "EDG", "SAT", ], }
[docs]class BreizhCropsDataset(CropsDataset): """""" schema = BreizhCropsSchema def __init__(self, config): super().__init__(config) # :param region: dataset region. choose from "frh01", "frh02", "frh03", "frh04", "belle-ile" # :param root: where the data will be stored. defaults to `./breizhcrops_dataset` # :param ф: year of the data. currently only `2017` # :param level: Sentinel 2 processing level. Either `L1C` (top of atmosphere) or `L2A` (bottom of atmosphere) # :param transform: a transformation function applied to the raw data before retrieving a sample. Can be used for featured extraction or data augmentaiton # :param target_transform: a transformation function applied to the label. # :param filter_length: time series shorter than `filter_length` will be ignored # :param bool verbose: verbosity flag # :param bool load_timeseries: if False, no time series data will be loaded. Only index file and class initialization. Used mostly for tests # :param bool recompile_h5_from_csv: downloads raw csv files and recompiles the h5 databases. Only required when dealing with new datasets # :param bool preload_ram: loads all time series data in RAM at initialization. Can speed up training if data is stored on HDD. self.regions = [region.lower() for region in self.config.regions] self.bands = BANDS[self.config.level] self.selected_bands = SELECTED_BANDS[self.config.level] self.root = self.config.root self.h5path = {} self.indexfile = {} self.shapefile = {} self.csvfolder = {} self.index = pd.DataFrame() self.preprocess()
[docs] def preprocess(self): for region in self.regions: if self.config.verbose: logging.info( f"Initializing BreizhCrops region {region}, year {self.config.year}, level {self.config.level}" ) ( self.h5path[region], self.indexfile[region], self.codesfile, self.shapefile[region], self.classmapping, self.csvfolder[region], ) = self.build_folder_structure( self.root, self.config.year, self.config.level, region ) self.load_classmapping(self.classmapping) logging.info("Path " + self.h5path[region]) if os.path.exists(self.h5path[region]): h5_database_ok = ( os.path.getsize(self.h5path[region]) == FILESIZES[self.config.year][self.config.level][region] ) else: h5_database_ok = False if not os.path.exists(self.indexfile[region]): download_file( INDEX_FILE_URLs[self.config.year][self.config.level][region], self.indexfile[region], ) if ( not h5_database_ok and self.config.recompile_h5_from_csv and self.config.load_timeseries ): self.download_csv_files(region) self.write_index(region) self.write_h5_database_from_csv(self.index, region) if ( not h5_database_ok and not self.config.recompile_h5_from_csv and self.config.load_timeseries ): self.download_h5_database(region) index_region = pd.read_csv(self.indexfile[region], index_col=None) index_region = index_region.loc[ index_region["CODE_CULTU"].isin(self.mapping.index) ] if ( "classid" not in index_region.columns or "classname" not in index_region.columns or "region" not in index_region.columns ): # drop fields that are not in the class mapping index_region = index_region.loc[ index_region["CODE_CULTU"].isin(self.mapping.index) ] index_region[["classid", "classname"]] = index_region[ "CODE_CULTU" ].apply(lambda code: self.mapping.loc[code]) index_region["region"] = region index_region.to_csv(self.indexfile[region]) if len(self.index.columns) == 0: self.index = pd.DataFrame(columns=index_region.columns) self.index = pd.concat( [self.index, index_region], axis=0, ignore_index=True ) if self.config.verbose: logging.info( f"kept {len(self.index)} time series references from applying class mapping" ) # filter zero-length time series if self.index.index.name != "idx": self.index = self.index.loc[ self.index.sequencelength > self.config.filter_length ] # set_index('idx') self.maxseqlength = int(self.index["sequencelength"].max()) if not os.path.exists(self.codesfile): download_file(CODESURL, self.codesfile) self.codes = pd.read_csv(self.codesfile, delimiter=";", index_col=0) # for now self.X_list = None self.index.rename(columns={"meanQA60": "meanCLD"}, inplace=True) self.get_codes()
def __len__(self): return len(self.index) def __getitem__(self, index): """ :param index: Index :type index: int :return: typle where target is index of the target class. :rtype: tuple (image, target) """ row = self.index.iloc[index] h5path = self.h5path[row.region] if self.X_list is None: # Looks like this is what I need (load directly from file) with h5py.File(h5path, "r") as dataset: X = np.array(dataset[(row.path)]) else: X = self.X_list[index] # translate CODE_CULTU to class id y = self.mapping.loc[row["CODE_CULTU"]].id if self.transform: X, y = self.transform((X, y)) return X, y # , row.id
[docs] def get_labels(self): return self.index.classid
# visualization functions
[docs] def data_distribution_table(self): label_count = ( self.index[["id", "region", "classname"]] .groupby(["classname", "region"]) .count() .reset_index() ) label_count.columns = ["Label", "Region", "Number of parcels"] return label_count
[docs] def parcel_distribution_table(self): # Figure 2 a) in the paper parcel_count = ( self.index[["id", "region"]].groupby("region").count().reset_index() ) parcel_count.columns = ["Region NUTS-3", "# " + self.config.level] total_row = parcel_count.sum(axis=0) total_row["Region NUTS-3"] = "Total" parcel_count = parcel_count.append(total_row, ignore_index=True) return parcel_count
[docs] def data_distribution_barchart(self): # Figure 2 b) in the paper label_count = self.data_distribution_table() fig, ax = plt.subplots(figsize=(12, 10)) g = sns.barplot( x="Label", y="Number of parcels", hue="Region", data=label_count, ax=ax ) g.set_xticklabels(g.get_xticklabels(), rotation=30) g.set_yscale("log") return fig
[docs] def show_samples(self): return self.index.head(20)
[docs] def show_timeseries(self, index): # Figure 3 in the paper X, _ = self.__getitem__(index) label = row = self.index.iloc[index].loc["classname"] fig, ax = plt.subplots(figsize=(8, 6)) ax.set_title( f"Timeseries with index {index} from the region {self.index.iloc[index].loc['region']}, with label {label}\n", fontsize=14, ) ax.plot(X) ax.legend(BANDS[self.config.level][: X.shape[1]]) ax.set_ylabel("ρ ") # x ${10^4}$ return fig
""" TODO: possibly move the download etc. somewhere else """
[docs] def download_csv_files(self, region): zipped_file = os.path.join( self.root, str(self.config.year), self.config.level, f"{region}.zip" ) download_file( RAW_CSV_URL[self.config.year][self.config.level][region], zipped_file ) unzip(zipped_file, self.csvfolder[region]) os.remove(zipped_file)
[docs] def build_folder_structure(self, root, year, level, region): """ Folder structure: .. code-block:: XML <root> codes.csv classmapping.csv <year> <region>.shp <level> <region>.csv <region>.h5 <region> <csv> 123123.csv 123125.csv ... """ year = str(year) os.makedirs(os.path.join(root, year, level, region), exist_ok=True) h5path = os.path.join(root, year, level, f"{region}.h5") indexfile = os.path.join(root, year, level, region + ".csv") codesfile = os.path.join(root, "codes.csv") shapefile = os.path.join(root, year, f"{region}.shp") classmapping = os.path.join(root, "classmapping.csv") csvfolder = os.path.join(root, year, level, region, "csv") return h5path, indexfile, codesfile, shapefile, classmapping, csvfolder
[docs] def get_fid(self, idx): return self.index[self.index["idx"] == idx].index[0]
[docs] def download_h5_database(self, region): logging.info(f"downloading {self.h5path[region]}.tar.gz") download_file( H5_URLs[self.config.year][self.config.level][region], self.h5path[region] + ".tar.gz", overwrite=True, ) logging.info(f"extracting {self.h5path[region]}.tar.gz to {self.h5path}") untar(self.h5path[region] + ".tar.gz") logging.info(f"removing {self.h5path[region]}.tar.gz") os.remove(self.h5path[region] + ".tar.gz") logging.info(f"checking integrity by file size...") assert ( os.path.getsize(self.h5path[region]) == FILESIZES[self.config.year][self.config.level][region] ) logging.info("ok!")
[docs] def write_h5_database_from_csv(self, index, region): with h5py.File(self.h5path[region], "w") as dataset: for idx, row in tqdm( index.iterrows(), total=len(index), desc=f"writing {self.h5path[region]}", ): X = self.load(os.path.join(self.root, row.path)) dataset.create_dataset(row.path, data=X)
[docs] def get_codes(self): return self.codes
[docs] def load_classmapping(self, classmapping): if not os.path.exists(classmapping): if self.config.verbose: print( f"no classmapping found at {classmapping}, downloading from {CLASSMAPPINGURL}" ) download_file(CLASSMAPPINGURL, classmapping) else: if self.config.verbose: print(f"found classmapping at {classmapping}") self.mapping = pd.read_csv(classmapping, index_col=0).sort_values(by="id") self.mapping = self.mapping.set_index("code") self.classes = self.mapping["id"].unique() self.classname = self.mapping.groupby("id").first().classname.values self.klassenname = self.classname self.nclasses = len(self.classes) if self.config.verbose: logging.info(f"read {self.nclasses} classes from {classmapping}")
[docs] def get_classes_to_ind(self, classmapping): """keep for now, could be needed to make it compatible with GenericMulticlass""" if not os.path.exists(classmapping): if self.config.verbose: logging.info( f"no classmapping found at {classmapping}, downloading from {CLASSMAPPINGURL}" ) download_file(CLASSMAPPINGURL, classmapping) else: if self.config.verbose: logging.info(f"found classmapping at {classmapping}") mapping = pd.read_csv(classmapping, index_col=0).sort_values(by="id") mapping = mapping[["id", "classname"]].groupby("id").first() mapping = mapping.set_index("classnamedf.to_dict('index')") return mapping.T.to_dict("records")[0]
[docs] def load_raw(self, csv_file): """ .. code-block:: python ['B1', 'B10', 'B11', 'B12', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'QA10', 'QA20', 'QA60', 'doa', 'label', 'id'] """ sample = pd.read_csv( os.path.join(self.csvfolder, os.path.basename(csv_file)), index_col=0 ).dropna() # convert datetime to int sample["doa"] = pd.to_datetime(sample["doa"]).astype(int) sample = sample.groupby(by="doa").first().reset_index() return sample
[docs] def load(self, csv_file): sample = self.load_raw(csv_file) selected_bands = SELECTED_BANDS[self.config.level] X = np.array(sample[selected_bands].values) if np.isnan(X).any(): t_without_nans = np.isnan(X).sum(1) > 0 X = X[~t_without_nans] return X
[docs] def load_culturecode_and_id(self, csv_file): sample = self.load_raw(csv_file) X = np.array(sample.values) if self.config.level == "L1C": cc_index = self.bands.index("label") else: cc_index = self.bands.index("code_cultu") id_index = self.bands.index("id") if len(X) > 0: field_id = X[0, id_index] culture_code = X[0, cc_index] return culture_code, field_id else: return None, None
[docs] def write_index(self, region): csv_files = os.listdir(self.csvfolder[region]) listcsv_statistics = list() i = 1 for csv_file in tqdm(csv_files): if self.config.level == "L1C": cld_index = SELECTED_BANDS["L1C"].index("QA60") elif self.config.level == "L2A": cld_index = SELECTED_BANDS["L2A"].index("CLD") X = self.load(os.path.join(self.csvfolder, csv_file)) culturecode, id = self.load_culturecode_and_id( os.path.join(self.csvfolder, csv_file) ) if culturecode is None or id is None: continue listcsv_statistics.append( dict( meanQA60=np.mean(X[:, cld_index]), id=id, CODE_CULTU=culturecode, path=os.path.join(self.csvfolder[region], f"{id}" + ".csv"), idx=i, sequencelength=len(X), ) ) i += 1 self.index = pd.DataFrame(listcsv_statistics) self.index.to_csv(self.indexfile)