From 034c16b50fdb815f03d636a85c38e7d0c4f26adf Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 5 Jun 2025 07:45:02 -0400 Subject: [PATCH 01/11] script to extract klv data and create resulting files --- scripts/fmv/kwiver-klv.py | 195 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 scripts/fmv/kwiver-klv.py diff --git a/scripts/fmv/kwiver-klv.py b/scripts/fmv/kwiver-klv.py new file mode 100644 index 0000000..ff3dfac --- /dev/null +++ b/scripts/fmv/kwiver-klv.py @@ -0,0 +1,195 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "click", +# "shapely", +# "pyproj", +# ] +# /// + +""" +KWIVER Dump to JSON Converter +Uses the kitware/kwiver docker file to run dump-klv to create +a CSV file with the output data (CSV is chosen becase the json file is a bit obtuse). +The CSV file is converted to a JSON file for easer processing and from there it creates 3 files: +output_metadata.geojson - geojson file with the camera footprint plus the camera positioning indexed by frame number +ground_frame_mapping.json - dictionary where the keys are video frames and the values are a bounding box representing the video location +bbox_frame.geojson - A geojson file of all of the ground mapped bounding boxes where each has a property of the frame. + +Usage: + uv kwiver-klv.py path/to/video.mpg ./output +""" + +import csv +import json +import subprocess +from pathlib import Path +import click +from shapely.geometry import Point, Polygon, mapping +from shapely.ops import unary_union +import pyproj + +@click.command() +@click.argument('video', type=click.Path(exists=True), required=True) +@click.argument('output_dir', type=click.Path(), default='./output') +def main(video, output_dir): + video_path = Path(video).resolve() + output_dir = Path(output_dir).resolve() + + output_dir.mkdir(parents=True, exist_ok=True) + + output_csv = output_dir / 'output.csv' + output_json = output_dir / 'output.json' + geojson_out = output_dir / 'output_metadata.geojson' + bbox_out = output_dir / 'ground_frame_mapping.json' + bbox_geojson_out = output_dir / 'bbox_frame.geojson' + click.echo(f'Processing video: {video_path}') + click.echo('Running KWIVER via Docker to extract metadata...') + + output_csv_file = Path(video_path.parent / 'output.csv') + cmd = [ + 'docker', 'run', '--rm', + '-v', f'{video_path.parent}:/data', + 'kitware/kwiver:latest', + 'dump-klv', f'/data/{video_path.name}', + '-l', f'/data/output.csv', + '-e', 'csv' + ] + + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + click.secho(f'Error running KWIVER: {e}', fg='red') + return + + if not output_csv_file.exists(): + click.secho('KWIVER did not generate the expected CSV output.', fg='red') + return + + click.echo(f'KWIVER output saved to {output_csv}') + + click.echo(f'Reading {output_csv_file} and converting to JSON...') + with open(output_csv_file, newline='') as csvfile: + reader = csv.DictReader(csvfile) + frames = [row for row in reader] + + with open(output_json, 'w') as f: + json.dump(frames, f, indent=2) + + click.secho(f'JSON output written to {output_json}', fg='green') + + create_geojson_and_bbox(frames, geojson_out, bbox_out, bbox_geojson_out) + click.secho(f'GeoJSON written to {geojson_out}', fg='cyan') + click.secho(f'Bounding box mapping written to {bbox_out}', fg='cyan') + + +def create_geojson_and_bbox(frames, geojson_out, bbox_out, bbox_geojson_out): + geod = pyproj.Geod(ellps='WGS84') + features = [] + polygons = [] + frame_polygons = [] + frame_to_bbox = {} + total = len(frames) + + for frame in frames: + try: + frame_id = frame.get("Frame ID", None) + if frame_id is None: + continue + + # Sensor location + sensor_lat = float(frame["Sensor Geodetic Latitude (EPSG:4326)"]) + sensor_lon = float(frame["Sensor Geodetic Longitude (EPSG:4326)"]) + + # Frame center and bounding + center_lat = float(frame["Geodetic Frame Center Latitude (EPSG:4326)"]) + center_lon = float(frame["Geodetic Frame Center Longitude (EPSG:4326)"]) + width = float(frame["Target Width (meters)"]) + + # Compute bounding box corners around center + corners = [] + for az in (0, 90, 180, 270): + lon, lat, _ = geod.fwd(center_lon, center_lat, az, width / 2) + corners.append((lon, lat)) + corners.append(corners[0]) # close the polygon + + polygon = Polygon(corners) + polygons.append(polygon) + frame_polygons.append((frame_id, polygon)) + frame_to_bbox[frame_id] = corners + + # Point feature at sensor location + point = Point(sensor_lon, sensor_lat) + feature = { + "type": "Feature", + "geometry": mapping(point), + "properties": { + "Frame ID": frame_id, + "Platform Ground Speed": frame.get("Platform Ground Speed (m/s)"), + "Platform Vertical Speed": frame.get("Platform Vertical Speed (m/s)") + } + } + features.append(feature) + + except (KeyError, ValueError) as e: + print(f"Skipping frame due to error: {e}") + continue + + # Add unioned polygon + if polygons: + merged = unary_union(polygons) + features.append({ + "type": "Feature", + "geometry": mapping(merged), + "properties": { + "type": "Unioned Bounding Box" + } + }) + + # Save GeoJSON + geojson = { + "type": "FeatureCollection", + "features": features + } + + with open(geojson_out, 'w') as f: + json.dump(geojson, f, indent=2) + + # Save bbox mapping + with open(bbox_out, 'w') as f: + json.dump(frame_to_bbox, f, indent=2) + + def get_gradient_color(idx, total): + r = int(255 * (idx / (total - 1))) + b = int(255 * (1 - idx / (total - 1))) + return f"#{r:02x}00{b:02x}" + + # Individual frame bbox polygons with styling + bbox_features = [] + for idx, (frame_id, poly) in enumerate(frame_polygons): + color = get_gradient_color(idx, total) + feature = { + "type": "Feature", + "geometry": mapping(poly), + "properties": { + "frame_id": frame_id, + "type": "Unioned Bounding Box", + "stroke": color, + "stroke-width": 2, + "stroke-opacity": 1, + "fill": "#ff0000", + "fill-opacity": 0.5 + } + } + bbox_features.append(feature) + + bbox_geojson = { + "type": "FeatureCollection", + "features": bbox_features + } + + with open(bbox_geojson_out, 'w') as f: + json.dump(bbox_geojson, f, indent=2) + +if __name__ == '__main__': + main() From 542664ce6e76b13ece5dbf8e594c312dd44d0d29 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 6 Jun 2025 19:04:43 -0400 Subject: [PATCH 02/11] kwiver dockerfile, fmv models, fmv ingestion task --- dev/kwiver.Dockerfile | 52 +++++ docker-compose.override.yml | 7 +- sample_data/fmv.json | 28 +++ uvdat/core/admin.py | 21 ++ .../0005_fmvlayer_fmvvectorfeature.py | 46 ++++ uvdat/core/models/__init__.py | 4 +- uvdat/core/models/map_layers.py | 62 ++++- uvdat/core/tasks/dataset.py | 22 +- uvdat/core/tasks/fmv.py | 215 ++++++++++++++++++ 9 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 dev/kwiver.Dockerfile create mode 100644 sample_data/fmv.json create mode 100644 uvdat/core/migrations/0005_fmvlayer_fmvvectorfeature.py create mode 100644 uvdat/core/tasks/fmv.py diff --git a/dev/kwiver.Dockerfile b/dev/kwiver.Dockerfile new file mode 100644 index 0000000..058bd7b --- /dev/null +++ b/dev/kwiver.Dockerfile @@ -0,0 +1,52 @@ +# Start from the KWIVER base image +FROM kitware/kwiver:latest + +# Install system packages needed for Python and the app +RUN apt-get update && apt-get install --yes --no-install-recommends \ + build-essential wget curl libssl-dev zlib1g-dev libbz2-dev \ + libreadline-dev libsqlite3-dev libncurses5-dev libncursesw5-dev \ + xz-utils tk-dev git libffi-dev liblzma-dev libpq-dev \ + libvips-dev gcc libc6-dev gdal-bin libgdal-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python 3.10 manually if not available +RUN wget https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tgz && \ + tar -xf Python-3.10.13.tgz && \ + cd Python-3.10.13 && \ + ./configure --enable-optimizations && \ + make -j"$(nproc)" && \ + make altinstall && \ + cd .. && rm -rf Python-3.10.13* + +RUN python3.10 --version + +# Install Python packages +RUN python3.10 -m ensurepip && \ + python3.10 -m pip install --upgrade pip + +# Install large-image +RUN python3.10 -m pip install large-image[gdal,pil] large-image-converter --find-links https://girder.github.io/large_image_wheels + +# Copy your application code +COPY ./setup.py /opt/uvdat-server/setup.py +COPY ./manage.py /opt/uvdat-server/manage.py +COPY ./uvdat /opt/uvdat-server/uvdat + +# Install uvdat in editable mode with dev dependencies +RUN python3.10 -m pip install --editable /opt/uvdat-server[dev] + +# Copy ffmpeg from static builder +RUN wget -O ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz +RUN mkdir /tmp/ffextracted +RUN tar -xvf ffmpeg.tar.xz -C /tmp/ffextracted --strip-components 1 + +# Copy ffmpeg into final image +RUN cp /tmp/ffextracted/ffmpeg /usr/local/bin/ +RUN cp /tmp/ffextracted/ffprobe /usr/local/bin/ + +# Setup environment + + +# Set working directory +WORKDIR /opt/uvdat-server + diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0e09f3f..69d6f21 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -2,7 +2,8 @@ services: django: build: context: . - dockerfile: ./dev/Dockerfile + dockerfile: ./dev/kwiver.Dockerfile + entrypoint: ["python3.10"] command: [ "./manage.py", "runserver", "0.0.0.0:8000" ] # Log printing via Rich is enhanced by a TTY tty: true @@ -15,12 +16,12 @@ services: - postgres - rabbitmq - minio - platform: linux/amd64 celery: build: context: . - dockerfile: ./dev/Dockerfile + dockerfile: ./dev/kwiver.Dockerfile + entrypoint: ["python3.10" , "-m"] command: [ "celery", diff --git a/sample_data/fmv.json b/sample_data/fmv.json new file mode 100644 index 0000000..4291cf7 --- /dev/null +++ b/sample_data/fmv.json @@ -0,0 +1,28 @@ +[{ + "type": "Context", + "name": "FMV", + "default_map_center": [ + 34.8019, + -86.1794 + ], + "default_map_zoom": 6, + "datasets": [ + { + "name": "FMV", + "description": "FMV Testing", + "category": "fmv", + "metadata": {}, + "files": [ + { + "path": "./data/FMV/fmv.mpg", + "url": "https://data.kitware.com/api/v1/file/604a5a532fa25629b931c673/download", + "name": "FMV Test Video", + "type": "fmv", + "action": "replace", + "metadata": { + } + } + ] + } + ] +}] \ No newline at end of file diff --git a/uvdat/core/admin.py b/uvdat/core/admin.py index 9bea800..2948f29 100644 --- a/uvdat/core/admin.py +++ b/uvdat/core/admin.py @@ -23,6 +23,8 @@ VectorFeatureRowData, VectorFeatureTableData, VectorMapLayer, + FMVLayer, + FMVVectorFeature ) @@ -73,6 +75,23 @@ def get_map_layer_index(self, obj): return obj.map_layer.index +class FMVLayerAdmin(admin.ModelAdmin): + list_display = ['id', 'name', 'dataset', 'get_dataset_name', 'index', 'geojson_file', 'fmv_video', 'bounds'] + + def get_dataset_name(self, obj): + return obj.dataset.name + + +class FMVVectorFeatureAdmin(admin.ModelAdmin): + list_display = ['id', 'get_dataset_name', 'get_map_layer_index'] + + def get_dataset_name(self, obj): + return obj.map_layer.dataset.name + + def get_map_layer_index(self, obj): + return obj.map_layer.index + + class SourceRegionAdmin(admin.ModelAdmin): list_display = ['id', 'name', 'get_dataset_name'] @@ -229,6 +248,8 @@ class DisplayConfigurationAdmin(admin.ModelAdmin): admin.site.register(RasterMapLayer, RasterMapLayerAdmin) admin.site.register(VectorMapLayer, VectorMapLayerAdmin) admin.site.register(VectorFeature, VectorFeatureAdmin) +admin.site.register(FMVLayer, FMVLayerAdmin) +admin.site.register(FMVVectorFeature, FMVVectorFeatureAdmin) admin.site.register(SourceRegion, SourceRegionAdmin) admin.site.register(DerivedRegion, DerivedRegionAdmin) admin.site.register(Network, NetworkAdmin) diff --git a/uvdat/core/migrations/0005_fmvlayer_fmvvectorfeature.py b/uvdat/core/migrations/0005_fmvlayer_fmvvectorfeature.py new file mode 100644 index 0000000..4e25996 --- /dev/null +++ b/uvdat/core/migrations/0005_fmvlayer_fmvvectorfeature.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.7 on 2025-06-06 19:10 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import django_extensions.db.fields +import s3_file_field.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_displayconfiguration'), + ] + + operations = [ + migrations.CreateModel( + name='FMVLayer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('metadata', models.JSONField(blank=True, null=True)), + ('default_style', models.JSONField(blank=True, null=True)), + ('index', models.IntegerField(null=True)), + ('name', models.CharField(blank=True, max_length=255)), + ('bounds', django.contrib.gis.db.models.fields.PolygonField(blank=True, help_text='Bounds/Extents of the Layer', null=True, srid=4326)), + ('fmv_source_video', s3_file_field.fields.S3FileField(null=True)), + ('fmv_video', s3_file_field.fields.S3FileField(null=True)), + ('geojson_file', s3_file_field.fields.S3FileField(null=True)), + ('dataset', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.dataset')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FMVVectorFeature', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('geometry', django.contrib.gis.db.models.fields.GeometryField(srid=4326)), + ('properties', models.JSONField()), + ('map_layer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.fmvlayer')), + ], + ), + ] diff --git a/uvdat/core/models/__init__.py b/uvdat/core/models/__init__.py index b7d2f56..de14561 100644 --- a/uvdat/core/models/__init__.py +++ b/uvdat/core/models/__init__.py @@ -5,7 +5,7 @@ from .file_item import FileItem from .layer_collection import LayerCollection from .layer_representation import LayerRepresentation -from .map_layers import AbstractMapLayer, RasterMapLayer, VectorFeature, VectorMapLayer +from .map_layers import AbstractMapLayer, RasterMapLayer, VectorFeature, VectorMapLayer, FMVLayer, FMVVectorFeature from .netcdf import NetCDFData, NetCDFImage, NetCDFLayer from .networks import Network, NetworkEdge, NetworkNode from .processing_task import ProcessingTask @@ -37,4 +37,6 @@ VectorFeatureTableData, VectorFeatureRowData, DisplayConfiguration, + FMVVectorFeature, + FMVLayer ] diff --git a/uvdat/core/models/map_layers.py b/uvdat/core/models/map_layers.py index f4093ed..75b8438 100644 --- a/uvdat/core/models/map_layers.py +++ b/uvdat/core/models/map_layers.py @@ -39,7 +39,7 @@ def set_bounds(self): self.bounds = Polygon.from_bbox( (bbox['xmin'], bbox['ymin'], bbox['xmax'], bbox['ymax']) ) - elif isinstance(self, VectorMapLayer): + elif isinstance(self, (VectorMapLayer, FMVLayer)): geojson_data = self.read_geojson_data() if 'features' in geojson_data: geometries = [shape(feature['geometry']) for feature in geojson_data['features']] @@ -104,6 +104,66 @@ def read_geojson_data(self) -> dict: return json.load(self.geojson_file.open()) +class FMVLayer(AbstractMapLayer): + fmv_source_video = S3FileField(null=True) + fmv_video = S3FileField(null=True) + geojson_file = S3FileField(null=True) + + def write_geojson_data(self, content: str | dict): + if isinstance(content, str): + data = content + elif isinstance(content, dict): + data = json.dumps(content) + else: + raise Exception(f'Invalid content type supplied: {type(content)}') + + self.geojson_file.save('vectordata.geojson', ContentFile(data.encode())) + + def read_geojson_data(self) -> dict: + """Read and load the data from geojson_file into a dict.""" + return json.load(self.geojson_file.open()) + + def get_ground_frame_mapping(self) -> dict: + """Return a mapping from frameId -> 4-point polygon coordinates.""" + result = {} + features = FMVVectorFeature.objects.filter( + map_layer=self, + properties__type='ground_frame', + ).exclude(properties__frameId__isnull=True) + + for feature in features: + frame_id = feature.properties.get("frameId") + geom = feature.geometry + + if geom.geom_type == "Polygon": + coords = list(geom.exterior.coords) + if len(coords) >= 4: + # Return first 4 unique corners, not repeating the closing point + result[frame_id] = coords[:4] + elif geom.geom_type == "MultiPolygon": + # Pick the largest polygon by area, then return its first 4 corners + largest = max(geom, key=lambda p: p.area) + coords = list(largest.exterior.coords) + if len(coords) >= 4: + result[frame_id] = coords[:4] + else: + # Optional: log or skip unexpected geometries + continue + + return result + + +@receiver(models.signals.pre_delete, sender=FMVLayer) +def delete__fmvvectorcontent(sender, instance, **kwargs): + if instance.geojson_file: + instance.geojson_file.delete(save=False) + +class FMVVectorFeature(models.Model): + map_layer = models.ForeignKey(FMVLayer, on_delete=models.CASCADE) + geometry = geomodels.GeometryField() + properties = models.JSONField() + + @receiver(models.signals.pre_delete, sender=VectorMapLayer) def delete__vectorcontent(sender, instance, **kwargs): if instance.geojson_file: diff --git a/uvdat/core/tasks/dataset.py b/uvdat/core/tasks/dataset.py index d8e0c89..6c323f4 100644 --- a/uvdat/core/tasks/dataset.py +++ b/uvdat/core/tasks/dataset.py @@ -14,6 +14,7 @@ process_geopackage, process_tabular_vector_feature_data, ) +from .fmv import create_fmv_layer from .netcdf import create_netcdf_data_layer from .networks import create_network from .regions import create_source_regions @@ -21,6 +22,19 @@ logger = logging.getLogger(__name__) +valid_video_format = ( + "mp4", + "webm", + "avi", + "mov", + "wmv", + "mpg", + "mpeg", + "mp2", + "ogg", + "flv", +) + @shared_task def convert_dataset( dataset_id, @@ -79,7 +93,8 @@ def convert_dataset( vector_map_layer.pk, tabular_geojson, tabular_matcher ) vector_map_layer.set_bounds() - + elif file_name.endswith(valid_video_format): + create_fmv_layer(file_to_convert, style_options, file_name, file_metadata) elif file_name.endswith(('.tif', '.tiff')): # Handle Raster files raster_map_layer = create_raster_map_layer( @@ -117,6 +132,7 @@ def process_file_item(self, file_item_id): raster_map_layers = [] vector_map_layers = [] netcdf_map_layers = [] + fmv_map_layers = [] processing_task.update(status=ProcessingTask.Status.RUNNING) try: if file_name.endswith('.gpkg'): @@ -141,6 +157,9 @@ def process_file_item(self, file_item_id): vector_map_layer.set_bounds() vector_map_layers.append(vector_map_layer) + elif file_name.endswith(valid_video_format): + fmv_map_layer = create_fmv_layer(file_item, style_options, file_name, file_metadata) + fmv_map_layers.append(fmv_map_layer) elif file_name.endswith(('.tif', '.tiff')): # Handle Raster files raster_map_layer = create_raster_map_layer( @@ -176,6 +195,7 @@ def process_file_item(self, file_item_id): 'raster_map_layers': [rml.id for rml in raster_map_layers], 'vector_map_layers': [vml.id for vml in vector_map_layers], 'net_cdf_map_layers': netcdf_map_layers, + 'fmv_map_layers': [fmv.id for fmv in fmv_map_layers] } }, ) diff --git a/uvdat/core/tasks/fmv.py b/uvdat/core/tasks/fmv.py new file mode 100644 index 0000000..f37e8fe --- /dev/null +++ b/uvdat/core/tasks/fmv.py @@ -0,0 +1,215 @@ + + +from collections import defaultdict +from functools import partial +import json +import logging +import os +from pathlib import Path +import subprocess +import tempfile +import zipfile +import csv + +from django.contrib.gis.geos import GEOSGeometry, LineString, MultiLineString +from django.core.files.base import ContentFile +import fiona +import geopandas +import pandas +import rasterio +from rasterio.enums import ColorInterp # Import ColorInterp from rasterio +import shapefile +from shapely.geometry import Point +from shapely.wkt import loads as wkt_loads +import subprocess + +from shapely.geometry import Point, Polygon, mapping +from shapely.ops import unary_union +import pyproj + + +from uvdat.core.models import ( + FMVLayer, + FMVVectorFeature, +) + +logger = logging.getLogger(__name__) + +def create_fmv_layer(file_item, style_options, file_name, index=None, metadata=None): + # First we grab the video file + with tempfile.TemporaryDirectory() as temp_dir: + video_ext = os.path.splitext(file_name)[1] + logger.info(f'file_name: {file_name} extension: {video_ext}') + raw_data_path = Path(temp_dir, f'video{video_ext}') + logger.info(f'RAW DATA PATH {raw_data_path}') + with open(raw_data_path, 'wb') as raw_data: + with file_item.file.open('rb') as raw_data_archive: + raw_data.write(raw_data_archive.read()) + # Now lets see if the FMV file has FMV data + output_csv = Path(temp_dir, 'output.csv') + cmd = ['bash', '/entrypoint.sh', 'dump-klv', str(raw_data_path), '-l', str(output_csv), '-e', 'csv'] + # cmd = [ + # '/opt/kitware/kwiver/bin/kwiver', + # 'dump-klv', str(raw_data_path), + # '-l', str(output_csv), + # '-e', 'csv' + # ] + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + logger.error(f'Error running KWIVER: {e}') + return + + if not output_csv.exists(): + logger.error('KWIVER did not generate the expected CSV output.') + return + + with open(output_csv, newline='') as csvfile: + reader = csv.DictReader(csvfile) + frames = [row for row in reader] + output_json = Path(temp_dir, 'output.json') + with open(output_json, 'w') as f: + json.dump(frames, f, indent=2) + + logger.info(f'JSON output written to {output_json}') + + logger.info("Creating the GeoJSON from the FMV Data") + frame_geojson = create_geojson_and_bbox(frames) + + transcoded_path = Path(temp_dir, 'transcoded.mp4') + transcode_cmd = [ + 'ffmpeg', + '-i', str(raw_data_path), + '-c:v', 'libx264', + '-preset', 'fast', + '-crf', '23', + str(transcoded_path) + ] + try: + subprocess.run(transcode_cmd, check=True) + except subprocess.CalledProcessError as e: + logger.error(f'Error during video transcoding: {e}') + return + + # Read video and GeoJSON into Django content fields + with open(transcoded_path, 'rb') as f: + fmv_video_file = ContentFile(f.read(), name='video.mp4') + + with open(raw_data_path, 'rb') as f: + raw_video_file = ContentFile(f.read(), name=file_name) + + geojson_str = json.dumps(frame_geojson, indent=2) + geojson_file = ContentFile(geojson_str.encode('utf-8'), name='vectordata.geojson') + + # Create FMVLayer object + fmv_layer = FMVLayer.objects.create( + dataset=file_item.dataset, + fmv_source_video=raw_video_file, + fmv_video=fmv_video_file, + geojson_file=geojson_file, + default_style=style_options, + metadata=metadata, + name=file_name + ) + + # Set bounds from GeoJSON + fmv_layer.set_bounds() + + # Create FMVVectorFeature entries + for feature in frame_geojson["features"]: + geometry = GEOSGeometry(json.dumps(feature["geometry"])) + FMVVectorFeature.objects.create( + map_layer=fmv_layer, + geometry=geometry, + properties=feature["properties"] + ) + + logger.info(f'Successfully created FMVLayer: {fmv_layer.id} with {len(frame_geojson["features"])} features') + return fmv_layer + + + +def create_geojson_and_bbox(frames,): + geod = pyproj.Geod(ellps='WGS84') + features = [] + polygons = [] + frame_polygons = [] + frame_to_bbox = {} + total = len(frames) + + for frame in frames: + try: + frame_id = frame.get("Frame ID", None) + if frame_id is None: + continue + + # Sensor location + sensor_lat = float(frame["Sensor Geodetic Latitude (EPSG:4326)"]) + sensor_lon = float(frame["Sensor Geodetic Longitude (EPSG:4326)"]) + + # Frame center and bounding + center_lat = float(frame["Geodetic Frame Center Latitude (EPSG:4326)"]) + center_lon = float(frame["Geodetic Frame Center Longitude (EPSG:4326)"]) + width = float(frame["Target Width (meters)"]) + + # Compute bounding box corners around center + corners = [] + for az in (0, 90, 180, 270): + lon, lat, _ = geod.fwd(center_lon, center_lat, az, width / 2) + corners.append((lon, lat)) + corners.append(corners[0]) # close the polygon + + polygon = Polygon(corners) + polygons.append(polygon) + frame_polygons.append((frame_id, polygon)) + frame_to_bbox[frame_id] = corners + + # Point feature at sensor location + point = Point(sensor_lon, sensor_lat) + feature = { + "type": "Feature", + "geometry": mapping(point), + "properties": { + "frameId": frame_id, + "type": "flight_path", + "Platform Ground Speed": frame.get("Platform Ground Speed (m/s)"), + "Platform Vertical Speed": frame.get("Platform Vertical Speed (m/s)") + } + } + features.append(feature) + + except (KeyError, ValueError) as e: + print(f"Skipping frame due to error: {e}") + continue + + # Add unioned polygon + if polygons: + merged = unary_union(polygons) + features.append({ + "type": "Feature", + "geometry": mapping(merged), + "properties": { + "type": "ground_union" + } + }) + + + # Individual frame bbox polygons with styling + for idx, (frame_id, poly) in enumerate(frame_polygons): + feature = { + "type": "Feature", + "geometry": mapping(poly), + "properties": { + "frameId": frame_id, + "type": "ground_frame", + } + } + features.append(feature) + + geojson = { + "type": "FeatureCollection", + "features": features + } + + return geojson + From 4e0ee68080160e8e53fa2682fda2329867824019 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 6 Jun 2025 19:53:56 -0400 Subject: [PATCH 03/11] FMVLayer endpoints --- docker-compose.override.yml | 1 + uvdat/core/models/map_layers.py | 23 ++++--- uvdat/core/rest/__init__.py | 2 + uvdat/core/rest/fmv.py | 106 ++++++++++++++++++++++++++++++++ uvdat/core/rest/map_layers.py | 29 ++++++++- uvdat/core/rest/serializers.py | 7 +++ uvdat/urls.py | 2 + 7 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 uvdat/core/rest/fmv.py diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 69d6f21..0136dcb 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -16,6 +16,7 @@ services: - postgres - rabbitmq - minio + platform: 'linux/amd64' celery: build: diff --git a/uvdat/core/models/map_layers.py b/uvdat/core/models/map_layers.py index 75b8438..729ff5c 100644 --- a/uvdat/core/models/map_layers.py +++ b/uvdat/core/models/map_layers.py @@ -136,18 +136,21 @@ def get_ground_frame_mapping(self) -> dict: geom = feature.geometry if geom.geom_type == "Polygon": - coords = list(geom.exterior.coords) - if len(coords) >= 4: - # Return first 4 unique corners, not repeating the closing point - result[frame_id] = coords[:4] + # Access first ring (exterior) without using .exterior + if len(geom) > 0: + coords = list(geom[0].coords) + if len(coords) >= 4: + result[frame_id] = coords[:4] + elif geom.geom_type == "MultiPolygon": - # Pick the largest polygon by area, then return its first 4 corners - largest = max(geom, key=lambda p: p.area) - coords = list(largest.exterior.coords) - if len(coords) >= 4: - result[frame_id] = coords[:4] + # Find largest polygon and access its first ring + largest = max(geom.geoms, key=lambda p: p.area) + if len(largest) > 0: + coords = list(largest[0].coords) + if len(coords) >= 4: + result[frame_id] = coords[:4] + else: - # Optional: log or skip unexpected geometries continue return result diff --git a/uvdat/core/rest/__init__.py b/uvdat/core/rest/__init__.py index 81a524e..eca52ea 100644 --- a/uvdat/core/rest/__init__.py +++ b/uvdat/core/rest/__init__.py @@ -15,6 +15,7 @@ from .tasks import TasksAPIView from .user import UserViewSet from .vector_feature_table_data import VectorFeatureTableDataViewSet +from .fmv import FMVLayerViewSet __all__ = [ ContextViewSet, @@ -40,4 +41,5 @@ TasksAPIView, MetadataFilterViewSet, DisplayConfigurationViewSet, + FMVLayerViewSet, ] diff --git a/uvdat/core/rest/fmv.py b/uvdat/core/rest/fmv.py new file mode 100644 index 0000000..7afd60b --- /dev/null +++ b/uvdat/core/rest/fmv.py @@ -0,0 +1,106 @@ +from django.db import connection +from django.http import HttpResponse +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet +from django.core.files.storage import default_storage + +from uvdat.core.models import FMVLayer + +FMV_TILE_SQL = """ +WITH tile_bounds AS ( + SELECT ST_Transform(ST_TileEnvelope(%(z)s, %(x)s, %(y)s), 4326) AS te +), +tilenvbounds as ( + SELECT + ST_XMin(te) as xmin, + ST_YMin(te) as ymin, + ST_XMax(te) as xmax, + ST_YMax(te) as ymax, + (ST_XMax(te) - ST_XMin(te)) / 4 as segsize + FROM tile_bounds +), +env as ( + SELECT ST_Segmentize( + ST_MakeEnvelope( + xmin, + ymin, + xmax, + ymax, + 4326 + ), + segsize + ) as seg + FROM tilenvbounds +), +bounds as ( + SELECT + seg as geom, + seg::box2d as b2d + FROM env +), +vector_features AS ( + SELECT + ST_AsMVTGeom( + ST_Transform(geometry, 3857), + ST_Transform((SELECT geom from bounds), 3857) + ) as geom, + map_layer_id, + id as fmvvectorfeatureid, + properties + FROM core_fmvvectorfeature + WHERE ST_Intersects(geometry, (SELECT geom from bounds)) + AND map_layer_id = %(map_layer_id)s +) +SELECT ST_AsMVT(vector_features.*) AS mvt FROM vector_features; +""" + +class FMVLayerViewSet(ViewSet): + """ + ViewSet for accessing FMVLayer data and tiles. + """ + def retrieve(self, request, pk=None): + try: + layer = FMVLayer.objects.get(pk=pk) + except FMVLayer.DoesNotExist: + return Response({"detail": "Not found."}, status=404) + + presigned_url = None + if layer.fmv_video and hasattr(layer.fmv_video, 'name'): + presigned_url = default_storage.url(layer.fmv_video.name) + else: + presigned_url = None + data = { + "id": layer.id, + "name": layer.name, + "bbox": list(layer.bounds.extent) if layer.bounds else None, + "frameId_to_bbox": layer.get_ground_frame_mapping(), + "fmv_video_url": presigned_url + } + return Response(data) + + @action( + detail=True, + methods=["get"], + url_path=r'tiles/(?P\d+)/(?P\d+)/(?P\d+)', + url_name='fmv_tiles', + ) + def get_vector_tile(self, request, pk=None, x=None, y=None, z=None): + with connection.cursor() as cursor: + cursor.execute( + FMV_TILE_SQL, + { + 'z': z, + 'x': x, + 'y': y, + 'map_layer_id': pk, + }, + ) + row = cursor.fetchone() + + tile = row[0] + return HttpResponse( + tile, + content_type='application/octet-stream', + status=200 if tile else 204, + ) diff --git a/uvdat/core/rest/map_layers.py b/uvdat/core/rest/map_layers.py index d9a9eeb..f453383 100644 --- a/uvdat/core/rest/map_layers.py +++ b/uvdat/core/rest/map_layers.py @@ -21,6 +21,8 @@ RasterMapLayer, VectorFeature, VectorMapLayer, + FMVLayer, + FMVVectorFeature, ) from uvdat.core.rest.serializers import ( AbstractMapLayerSerializer, @@ -28,6 +30,7 @@ RasterMapLayerSerializer, VectorMapLayerDetailSerializer, VectorMapLayerSerializer, + FMVLayerSerializer, ) from .permissions import DefaultPermission @@ -466,7 +469,8 @@ def create(self, request, *args, **kwargs): raster_layer = RasterMapLayer.objects.filter(id=layer_id).first() vector_layer = VectorMapLayer.objects.filter(id=layer_id).first() netcdf_layer = NetCDFLayer.objects.filter(id=layer_id).first() - map_layer = raster_layer or vector_layer or netcdf_layer + fmv_layer = FMVLayer.objects.filter(id=layer_id).first() + map_layer = raster_layer or vector_layer or netcdf_layer or fmv_layer if map_layer is None: continue # Skip if no layer is found for the provided ID @@ -478,6 +482,8 @@ def create(self, request, *args, **kwargs): serializer = VectorMapLayerSerializer(map_layer) elif isinstance(map_layer, NetCDFLayer): serializer = NetCDFLayerSerializer(map_layer) + elif isinstance(map_layer, FMVLayer): + serializer = FMVLayerSerializer(map_layer) # Get the serialized data layer_response = serializer.data if raster_layer: @@ -486,6 +492,8 @@ def create(self, request, *args, **kwargs): layer_response['type'] = 'vector' elif netcdf_layer: layer_response['type'] = 'netcdf' + elif fmv_layer: + layer_response['type'] = 'fmv' # Check for LayerRepresentation if provided if layer_representation_id is not None: @@ -525,12 +533,14 @@ def list_all_map_layers(self, request, *args, **kwargs): raster_layers = RasterMapLayer.objects.all() vector_layers = VectorMapLayer.objects.all() netcdf_layers = NetCDFLayer.objects.all() + fmv_layers = FMVLayer.objects.all() # Serialize layers for map_layer, _serializer, layer_type in [ (raster_layers, RasterMapLayerSerializer, 'raster'), (vector_layers, VectorMapLayerSerializer, 'vector'), (netcdf_layers, NetCDFLayerSerializer, 'netcdf'), + (fmv_layers, FMVLayer, 'fmv'), ]: for layer in map_layer: serializer = AbstractMapLayerSerializer(layer) @@ -577,6 +587,9 @@ def list(self, request, *args, **kwargs): if not map_layer and 'netcdf' == layer_type: map_layer = NetCDFLayer.objects.filter(id=layer_id).first() serializer_class = NetCDFLayerSerializer + if not map_layer and 'fmv' == layer_type: + map_layer = FMVLayer.objects.filter(id=layer_id).first() + serializer_class = FMVLayerSerializer if not map_layer: continue # Skip if no matching layer is found @@ -619,6 +632,7 @@ def map_layer_bbox(self, request, *args, **kwargs): raster_map_layer_ids = request.query_params.getlist('rasterMapLayerIds') vector_map_layer_ids = request.query_params.getlist('vectorMapLayerIds') netcdf_map_layer_ids = request.query_params.getlist('netCDFMapLayerIds') + fmv_map_layer_ids = request.query_params.getlist('fmvMapLayerIds') # Initialize variables to track the overall bounding box overall_bbox = { @@ -663,6 +677,17 @@ def map_layer_bbox(self, request, *args, **kwargs): overall_bbox['ymin'] = min(overall_bbox['ymin'], netcdf_bbox[1]) overall_bbox['xmax'] = max(overall_bbox['xmax'], netcdf_bbox[2]) overall_bbox['ymax'] = max(overall_bbox['ymax'], netcdf_bbox[3]) + if fmv_map_layer_ids: + fmv_bboxes = FMVVectorFeature.objects.filter( + map_layer_id__in=fmv_map_layer_ids + ).aggregate(extent=Extent('geometry'))['extent'] + + if vector_bboxes: + overall_bbox['xmin'] = min(overall_bbox['xmin'], vector_bboxes[0]) + overall_bbox['ymin'] = min(overall_bbox['ymin'], vector_bboxes[1]) + overall_bbox['xmax'] = max(overall_bbox['xmax'], vector_bboxes[2]) + overall_bbox['ymax'] = max(overall_bbox['ymax'], vector_bboxes[3]) + # Check if the bbox values were updated; if not, return an error message if overall_bbox['xmin'] == float('inf'): return JsonResponse( @@ -697,6 +722,8 @@ def update_name(self, request, *args, **kwargs): RasterMapLayer.objects.filter(id=layer_id).update(name=new_name) elif layer_type == 'netcdf': NetCDFData.objects.filter(id=layer_id).update(name=new_name) + elif layer_type == 'fmv': + FMVLayer.objects.filter(id=layer_id).update(name=new_name) else: return Response( {'error': 'Invalid layer type. Must be "vector" or "raster".'}, diff --git a/uvdat/core/rest/serializers.py b/uvdat/core/rest/serializers.py index 7ba9f73..0232446 100644 --- a/uvdat/core/rest/serializers.py +++ b/uvdat/core/rest/serializers.py @@ -24,6 +24,8 @@ VectorFeatureRowData, VectorFeatureTableData, VectorMapLayer, + FMVLayer, + FMVVectorFeature, ) @@ -187,6 +189,11 @@ class Meta: model = VectorMapLayer exclude = ['geojson_file'] +class FMVLayerSerializer(serializers.ModelSerializer, AbstractMapLayerSerializer): + class Meta: + model = FMVLayer + exclude = ['geojson_file'] + class NetCDFLayerSerializer(serializers.ModelSerializer): bounds = serializers.SerializerMethodField() diff --git a/uvdat/urls.py b/uvdat/urls.py index ec37311..b182bea 100644 --- a/uvdat/urls.py +++ b/uvdat/urls.py @@ -28,6 +28,7 @@ UserViewSet, VectorFeatureTableDataViewSet, VectorMapLayerViewSet, + FMVLayerViewSet, ) router = routers.SimpleRouter() @@ -59,6 +60,7 @@ router.register(r'layer-collections', LayerCollectionViewSet, basename='layer-collections') router.register(r'map-layers', MapLayerViewSet, basename='map-layers') router.register(r'netcdf', NetCDFDataView, basename='netcdf') +router.register(r'fmv-layer', FMVLayerViewSet, basename='fmv-layer') router.register(r'processing-tasks', ProcessingTaskView, basename='processing-tasks') router.register(r'users', UserViewSet, basename='users') router.register(r'tasks', TasksAPIView, basename='tasks') From 68395575162c3c9d3349cf7df6c6e858047b7f3b Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Sat, 7 Jun 2025 23:14:18 -0400 Subject: [PATCH 04/11] client side beginnings --- client/src/MapStore.ts | 13 +- client/src/api/UVDATApi.ts | 14 +- .../components/DataSelection/DatasetItem.vue | 8 +- client/src/components/FMVLayerConfig.vue | 199 ++++++++ client/src/components/LayerConfig.vue | 12 +- client/src/map/fmvStore.ts | 178 ++++++++ client/src/map/mapFMVLayer.ts | 426 ++++++++++++++++++ client/src/map/mapLayers.ts | 13 +- client/src/types.ts | 17 +- uvdat/core/admin.py | 2 +- ...v_fps_fmvlayer_fmv_frame_count_and_more.py | 38 ++ uvdat/core/models/dataset.py | 2 +- uvdat/core/models/map_layers.py | 27 +- uvdat/core/rest/dataset.py | 13 +- uvdat/core/rest/fmv.py | 44 +- uvdat/core/rest/serializers.py | 11 +- uvdat/core/tasks/fmv.py | 48 +- 17 files changed, 1023 insertions(+), 42 deletions(-) create mode 100644 client/src/components/FMVLayerConfig.vue create mode 100644 client/src/map/fmvStore.ts create mode 100644 client/src/map/mapFMVLayer.ts create mode 100644 uvdat/core/migrations/0006_fmvlayer_fmv_fps_fmvlayer_fmv_frame_count_and_more.py diff --git a/client/src/MapStore.ts b/client/src/MapStore.ts index 90de437..a5e5e83 100644 --- a/client/src/MapStore.ts +++ b/client/src/MapStore.ts @@ -8,6 +8,7 @@ import { Context, Dataset, DisplayConfiguration, + FMVLayer, LayerCollection, NetCDFData, NetCDFImageWorking, @@ -66,9 +67,9 @@ export default class MapStore { public static datasetsByContext = reactive>({}); // Layers - public static mapLayersByDataset = reactive>({}); + public static mapLayersByDataset = reactive>({}); - public static selectedMapLayers = ref<(VectorMapLayer | RasterMapLayer | NetCDFLayer)[]>([]); + public static selectedMapLayers = ref<(VectorMapLayer | RasterMapLayer | NetCDFLayer | FMVLayer)[]>([]); public static visibleMapLayers: Ref> = ref(new Set()); @@ -88,6 +89,10 @@ export default class MapStore { () => this.selectedMapLayers.value.filter((layer) => layer.type === 'netcdf'), ); + public static selectedFMVMapLayers: Ref = computed( + () => this.selectedMapLayers.value.filter((layer) => layer.type === 'fmv'), + ); + public static async loadCollections() { MapStore.availableCollections.value = await UVdatApi.getLayerCollections(); } @@ -121,8 +126,8 @@ export default class MapStore { if (initial && MapStore.displayConfiguration.value.default_displayed_layers.length) { const datasetIds = MapStore.displayConfiguration.value.default_displayed_layers.map((item) => item.dataset_id); const datasetIdLayers = await UVdatApi.getDatasetsLayers(datasetIds); - const layerByDataset: Record = {}; - const toggleLayers: (VectorMapLayer | RasterMapLayer | NetCDFLayer)[] = []; + const layerByDataset: Record = {}; + const toggleLayers: (VectorMapLayer | RasterMapLayer | NetCDFLayer | FMVLayer)[] = []; const enabledLayers = MapStore.displayConfiguration.value.default_displayed_layers; datasetIdLayers.forEach((item) => { if (item.dataset_id !== undefined) { diff --git a/client/src/api/UVDATApi.ts b/client/src/api/UVDATApi.ts index 7825bb7..165f271 100644 --- a/client/src/api/UVDATApi.ts +++ b/client/src/api/UVDATApi.ts @@ -14,6 +14,8 @@ import { FeatureGraphs, FeatureGraphsRequest, FileItem, + FMVLayer, + FMVLayerData, LayerCollection, LayerCollectionLayer, LayerRepresentation, @@ -363,7 +365,7 @@ export default class UVdatApi { public static async getMapLayerCollectionList( layers: LayerCollectionLayer[], enabled? : boolean, - ): Promise<(VectorMapLayer | RasterMapLayer | NetCDFLayer)[]> { + ): Promise<(VectorMapLayer | RasterMapLayer | NetCDFLayer | FMVLayer)[]> { return (await UVdatApi.apiClient.post('/map-layers/', { layers }, { params: { enabled } })).data; } @@ -375,6 +377,10 @@ export default class UVdatApi { return (await UVdatApi.apiClient.get(`/vectors/${mapLayerId}/bbox`)).data; } + public static async getFMVBbox(mapLayerId: number): Promise { + return (await UVdatApi.apiClient.get(`/fmv-layer/${mapLayerId}/bbox`)).data; + } + public static async getMapLayersBoundingBox( rasterMapLayerIds: number[] = [], vectorMapLayerIds: number[] = [], @@ -626,7 +632,7 @@ export default class UVdatApi { public static async getMapLayerList( layerIds: number[], layerTypes : AbstractMapLayer['type'][], - ): Promise<(VectorMapLayer | RasterMapLayer | NetCDFLayer)[]> { + ): Promise<(VectorMapLayer | RasterMapLayer | NetCDFLayer | FMVLayer)[]> { const params = new URLSearchParams(); layerIds.forEach((id) => params.append('mapLayerIds', id.toString())); @@ -663,4 +669,8 @@ export default class UVdatApi { const response = await UVdatApi.apiClient.patch('display-configuration/', config); return response.data; } + + public static async getFMVLayerData(layerId: number): Promise { + return (await UVdatApi.apiClient.get(`/fmv-layer/${layerId}/`)).data; + } } diff --git a/client/src/components/DataSelection/DatasetItem.vue b/client/src/components/DataSelection/DatasetItem.vue index 14a3f39..0e9ce7b 100644 --- a/client/src/components/DataSelection/DatasetItem.vue +++ b/client/src/components/DataSelection/DatasetItem.vue @@ -5,7 +5,9 @@ import { onUnmounted, ref, } from 'vue'; -import { NetCDFData, RasterMapLayer, VectorMapLayer } from '../../types'; +import { + FMVLayer, NetCDFData, RasterMapLayer, VectorMapLayer, +} from '../../types'; import { toggleLayerSelection } from '../../map/mapLayers'; import MapStore from '../../MapStore'; import NetCDFDataConfigurator from './NetCDFDataConfigurator.vue'; @@ -16,7 +18,7 @@ export default defineComponent({ }, props: { layer: { - type: Object as PropType, + type: Object as PropType, required: true, }, }, @@ -71,7 +73,7 @@ export default defineComponent({