DOI: 10.5281/zenodo.15743988
title: GVCCS : Ground Visible Camera Contrail Sequences

description: The GVCCS dataset provides the first open-access, instance-level annotated video dataset for contrail detection, segmentation, and tracking from Réuniwatt CamVision visible ground-based camera. Designed to support research into aviation’s non-CO₂ climate impacts, it contains 122 high-resolution video sequences (24,228 images) captured at EUROCONTROL’s Innovation Hub in Brétigny-sur-Orge, France.

Each sequence has been carefully labelled with instance-level multi polygon annotations, including temporally resolved contrail masks and consistent instance identifiers. A subset of contrails is also linked to unique flight identifiers (when attribution is possible) based on known aircraft trajectories.

The dataset is aimed at researchers in environmental science, aviation, remote sensing, and computer vision. It supports both semantic and panoptic segmentation tasks and is intended to foster development of models for:



Contrail detection and instance segmentation

Temporal tracking and lifecycle analysis

Contrail-to-flight attribution and validation of physical models


Key Features:



24,228 annotated images across 122 video sequences (30sec frame rate) with a total of 176,194 individual polygons.

4651 instance-level multi-polygon contrails with temporal consistency (contrails are tracked across frames).

3354 contrails are associated to a flight with unique identifier.

Image resolution: 1024×1024 (geometrically projected from fisheye camera) covering an area of 75km x 75km.


 Folder organisation : 

GVCCS/├── azimuth_zenith_grid.npy├── train/│   ├── annotations.json│   ├── parquet/│   └── images/├── test/│   ├── annotations.json│   ├── parquet/│   └── images/



azimuth_zenith_grid.npyis the grid of pixel to azitmuth zenith mapping. (warning file not available in dataset v0.1 if needed see v1.1)



Both train and test folders have a similar structure, with images, annotations, csv files.


Images are high-resolution (1024×1024 pixels JPG files) stored in the images subfolders. Each projected image has been enhanced for visual clarity using brightness adjustment, local contrast amplification, and color rebalancing to improve contrail visibility (see paper description for more details).



The parquetfolders include flight data for each sequence saved in parquet format. Each parquet file contains tabular data for the flight passing over the camera filtered with altitude higher than 15 000ft with the following columns:



 




Column
Description




FLIGHT_ID
Unique identifier for each flight


CALL_SIGN
Airline call sign (flight number)


REGISTRATION
Aircraft registration code


ICAO_TYPE
Aircraft type code according to ICAO classification


TIMESTAMP_S
Timestamp of the data point in seconds since epoch (warning timestamps are UTC-2 please see v1.1 for UTC)


LONGITUDE
Longitude of the aircraft at the timestamp


LATITUDE
Latitude of the aircraft at the timestamp


ALTI_FT
Standard Pressure Altitude in feet


GRND_SPD
Ground speed of the aircraft in knots


AZIMUTH
Aircraft heading (azimuth) in radian


ZENITH
Zenith angle (vertical angle) in raduan


PIXEL_X
Projected X pixel coordinate corresponding to the aircraft position in the image


PIXEL_Y
Projected Y pixel coordinate corresponding to the aircraft position in the image




 





The annotations.json files are provided in COCO format, including per-frame and per-instance polygon annotations, object tracking (consistent instance IDs across frames), flight attribution (when available), and video-level metadata. It contains four main sections with the following fields:



annotations





area: Area of the annotated polygon (float)




bbox: Bounding box of the instance [x, y, width, height]




category_id: ID referring to the object category




contrail_id: Unique ID of the contrail instance (consistent across frames)




flight_id: ID linking the annotation to the flight data (if available)




id: Unique annotation ID




image_id: ID of the associated image




iscrowd: Boolean flag for crowd annotations




segmentation: Polygon coordinates describing the shape




type: Type of annotation (e.g., polygon)



categories (only one category: contrail)





color: Color associated with the category (for visualization)




id: Category ID




isthing: Boolean flag indicating whether the category is a "thing" (vs. stuff)




name: Category name



images





file_name: Image file name




height: Image height (pixels)




id: Unique image ID




time: Timestamp of the image capture




video_id: ID of the associated video (if applicable)




width: Image width (pixels)



videos





height: Video frame height (pixels)




id: Unique video ID




length: Number of frames in the video




start: Start timestamp of the video




stop: End timestamp of the video




width: Video frame width (pixels)



 

Code example to retrieve coordinates from pixels

import os
import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import RegularGridInterpolator
from geographiclib import geodesic


from scipy.interpolate import LinearNDInterpolator, RegularGridInterpolator, NearestNDInterpolator
from geographiclib import geodesic
from typing import Tuple, Union
import numpy as np 

RESOLUTION=1024
wgs84 = geodesic.Geodesic.WGS84
 
class Projector:
    def __init__(self, 
                 azimuth_zenith_grid: np.typing.NDArray, 
                 resolution: int = RESOLUTION,
                 azimuth: float = 270.,
                 height_above_ground_m: float = 90., 
                 longitude_deg: float = 2.3467954996250784, 
                 latitude_deg: float = 48.600518087374105):
        self.resolution = resolution
        self.azimuth = azimuth 
        grid_size = azimuth_zenith_grid.shape[0]
        if grid_size == self.resolution:
            extended_grid = False
        elif grid_size == (self.resolution + 2):
            extended_grid = True
        else:
            raise ValueError("Grid size must be equal to resolution for regular grid, or resolution + 2 for extended grid")
        self.height_above_ground_m = height_above_ground_m
        self.longitude_deg = longitude_deg
        self.latitude_deg = latitude_deg
        # Get azimuth and zenith
        azimuth_grid = azimuth_zenith_grid[..., 0]
        zenith_grid = azimuth_zenith_grid[..., 1]
        # Compute components
        cos_grid = np.cos(azimuth_grid)
        sin_grid = np.sin(azimuth_grid)
        # Generate interpolator
        values_grid = np.stack([cos_grid, sin_grid, zenith_grid], axis=-1)
        # Define the regular grid coordinates for the interpolator
        origin = - 0.5 if extended_grid else 0.5
        x = np.arange(grid_size) + origin  
        # Create the interpolator (maps PIXEL in ENCORD (x, y) -> (cos(az), sin(az), zenith))
        self.pixel_to_azzen_regular = RegularGridInterpolator(
            (x, x),
            values_grid,
            bounds_error=False,
            fill_value=None # Extrapolate!
        )
        # Flatten pixel coordinates
        pixels_flat = np.stack(np.meshgrid(x, x, indexing='ij'), axis=-1).reshape(-1, 2)
        # Flatten unwrapped azimuth and zenith
        azimuth_flat = azimuth_grid.flatten()
        zenith_flat = zenith_grid.flatten()
 
        # Combine as input for the interpolator
        values_flat = np.stack([azimuth_flat, zenith_flat], axis=-1)
 
        # Create reverse interpolator: (azimuth (unwarapped), zenith) → (pixel_x, pixel_y)
        self.azzen_to_pixel_linear = LinearNDInterpolator(values_flat, pixels_flat)

    def pixel_to_azzen(self, 
                       x: np.typing.NDArray,
                       y: np.typing.NDArray) -> Tuple[np.typing.NDArray, np.typing.NDArray]:
        # Clip pixels to image resolution
        x = self.resolution - np.asarray(x)
        y = np.asarray(y)
        points = np.column_stack((x, y))
        # Clip pixels to image resolution
        points = np.clip(points, a_min=0, a_max=self.resolution) 
        result = self.pixel_to_azzen_regular(points) 
        azimuth_rad = np.arctan2(result[:, 1], result[:, 0])
        zenith_rad = result[:, 2]
        return azimuth_rad, zenith_rad

 
        return azimuth_rad, zenith_rad
    def azzen_to_lonlat(self, 
                        azimuth_rad: np.typing.NDArray, 
                        zenith_rad: np.typing.NDArray,
                        altitude_m: np.typing.NDArray) -> Tuple[np.typing.NDArray, np.typing.NDArray]:
        azimuth_rad = np.asarray(azimuth_rad)
        zenith_rad = np.asarray(zenith_rad)
        altitude_m = np.asarray(altitude_m)
 
        # Convert to elevation angle in radians
        elevation_angle_rad = (np.pi / 2.) - zenith_rad
 
        # Altitude difference
        delta_altitude_m = altitude_m - self.height_above_ground_m
 
        # Distance on surface
        distance_on_surface_m = delta_altitude_m / np.tan(elevation_angle_rad)
 
        # Convert azimuth to degrees
        azimuth_deg = np.degrees(azimuth_rad) + self.azimuth  
 
        def compute_direct(azimuth_deg, distance_on_surface_m):
            result = wgs84.Direct(self.latitude_deg, self.longitude_deg, azimuth_deg, distance_on_surface_m)
            return result['lon2'], result['lat2']
 
        compute_vec = np.vectorize(compute_direct, otypes=[float, float])
        lon2, lat2 = compute_vec(azimuth_deg, distance_on_surface_m)
 
        return lon2, lat2


# Load azimuth-zenith grid
folder_path = "YOUR_PATH/GVCCS"
grid_path = os.path.join(folder_path, "azimuth_zenith_grid.npy")
azimuth_zenith_grid = np.load(grid_path)

projector = Projector(azimuth_zenith_grid)

# Define polygon vertices in pixel coordinates (corners of the image here)
pixel_polygon = np.array([
    [0, 0], 
    [0, RESOLUTION-1], 
    [RESOLUTION-1, RESOLUTION-1], 
    [RESOLUTION-1, 0]
])

# Interpolate azimuth and zenith for polygon corners
azimuths, zeniths = projector.pixel_to_azzen(pixel_polygon[:,0], pixel_polygon[:,1])

# Altitude hypothesis
target_altitude_m = 10000

# Convert polygon corners from azimuth/zenith to lat/lon
longitudes, latitudes = projector.azzen_to_lonlat(azimuths, zeniths, target_altitude_m)

# Close polygon for plotting
latitudes = np.append(latitudes, latitudes[0])
longitudes = np.append(longitudes, longitudes[0])

# Plot polygon in lat/lon
plt.figure(figsize=(8,6))
plt.plot(longitudes, latitudes, marker='o')
plt.plot(longitudes[:1], latitudes[:1], marker='o',color='r')
plt.title(f'Polygon projected from pixel coordinates to lat/lon at {target_altitude_m} m altitude')
plt.xlabel('Longitude')
plt.ylabel('Latitude')
plt.grid(True)
plt.show()

If you use GVCCS in your work and want to compare with our baselines, please cite the following references:

Dataset :



@dataset{jarry_2025_15743988,  author       = {Jarry, Gabriel and                  Very, Philippe and                  Ballerini, Franck and                  Dalmau, Ramon},  title        = {GVCCS : Ground Visible Camera Contrail Sequences},  month        = jul,  year         = 2025,  publisher    = {Zenodo},  version      = {v0.1},  doi          = {10.5281/zenodo.15743988},  url          = {https://doi.org/10.5281/zenodo.15743988},}


Paper baselines : 



@misc{jarry2025gvccsdatasetcontrailidentification,      title={GVCCS: A Dataset for Contrail Identification and Tracking on Visible Whole Sky Camera Sequences},       author={Gabriel Jarry and Ramon Dalmau and Philippe Very and Franck Ballerini and Stephania-Denisa Bocu},      year={2025},      eprint={2507.18330},      archivePrefix={arXiv},      primaryClass={cs.CV},      url={https://arxiv.org/abs/2507.18330}, }


License:

CC BY 4.0