diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ee68b..dbc41a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [0.2.3] - 2025-11-17 +### Added +- Camera config admin view now also displays a 3D view of the camera configuration. +### Changed +### Deprecated +### Removed +### Fixed +- Camera config admin view checks if CRS is provided. If not, the map view is not displayed. +- PATCH /api/site//cameraconfig/ was not working, now properly functions. +### Security + + ## [0.2.2] - 2025-10-02 ### Added ### Changed diff --git a/Dockerfile b/Dockerfile index 1dc73fe..3b77c2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,8 @@ COPY django-admin-interface/media /liveorc/media RUN apt update && apt install ffmpeg libsm6 libxext6 libgl1 python3-venv libgdal-dev libsqlite3-mod-spatialite nginx certbot gettext dos2unix cron -y && \ # setup application with database pip install --upgrade pip && pip install --trusted-host pypi.python.org --requirement requirements.txt && pip install gunicorn && \ + # override pyopenrivercam version + pip install --upgrade pyopenrivercam && \ # make scripts executable and run as unix dos2unix /liveorc/start.sh && dos2unix /liveorc/nginx/letsencrypt-autogen.sh && dos2unix /liveorc/nginx/crontab && \ chmod +x /liveorc/start.sh && chmod +x /liveorc/nginx/letsencrypt-autogen.sh && chmod +x /liveorc/nginx/crontab && \ diff --git a/LiveORC/settings.py b/LiveORC/settings.py index fb77e05..4d7b1ac 100644 --- a/LiveORC/settings.py +++ b/LiveORC/settings.py @@ -16,7 +16,7 @@ import boto3 # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ -VERSION = "0.2.2" +VERSION = "0.2.3" # Build paths inside the project like this: BASE_DIR / 'subdir'. # try to get BASE_DIR from env variable BASE_DIR = Path(__file__).resolve().parent.parent diff --git a/api/admin/camera_config.py b/api/admin/camera_config.py index 276182f..1d122bf 100644 --- a/api/admin/camera_config.py +++ b/api/admin/camera_config.py @@ -110,7 +110,8 @@ class Media: "width", "resolution", "window_size", - "bounding_box_view" + "bounding_box_view", + "bounding_box_3d_view" ]} ) ] @@ -147,7 +148,7 @@ def get_urls(self): list_filter = [SiteUserFilter] form = CameraConfigForm # inlines = [VideoInline] - readonly_fields = ["bounding_box_view", "height", "width", "resolution", "window_size", "bbox"] + readonly_fields = ["bounding_box_view", "bounding_box_3d_view", "height", "width", "resolution", "window_size", "bbox"] formfield_overrides = {} @admin.display(ordering='site__name', description="Site") @@ -228,6 +229,13 @@ def save_model(self, request, obj, form, change): form.instance.camera_config = json.load(request._files["json_file"]) super().save_model(request, obj, form, change) + @admin.display(description='Camera calibration geographical view') def bounding_box_view(self, obj): return obj.bbox_view + # bounding_box_view.short_description = 'Thumbnail' + # thumbnail_preview.allow_tags = True + + @admin.display(description='Camera calibration 3D view') + def bounding_box_3d_view(self, obj): + return obj.bbox_plot_3d diff --git a/api/callback_utils.py b/api/callback_utils.py index 5cade7e..6a9bc4e 100644 --- a/api/callback_utils.py +++ b/api/callback_utils.py @@ -93,6 +93,7 @@ def get_form_callback_discharge_post(instance): return models.Callback( func_name="discharge", request_type="POST", + kwargs={}, endpoint=reverse( "api:site-timeseries-list", args=([ diff --git a/api/models/camera_config.py b/api/models/camera_config.py index f79c084..4e3c6b9 100644 --- a/api/models/camera_config.py +++ b/api/models/camera_config.py @@ -1,5 +1,12 @@ from datetime import timedelta +import base64 +import geopandas as gpd +import io +import matplotlib.pyplot as plt +import matplotlib +matplotlib.use("Agg") # make sure a non-interactive backend is used only import shapely.wkt + from django.contrib.auth import get_user_model from django.contrib.gis.db import models from django.contrib.gis.geos import GEOSGeometry @@ -9,10 +16,12 @@ from django.utils.translation import gettext_lazy from pyproj import CRS, Transformer from shapely import ops +from shapely.geometry import Point from api.models import BaseModel, Site, Server, Recipe, Profile import pyorc +from api.tests.test_api_video import camera_config map_template = """
@@ -55,6 +64,14 @@ """ +# Simple HTML template for non‑geographic x/y plot +bbox_plot_3d_template = """ +
+ Bounding box and profile plot +
+ +""" + lens_position_schema = { 'schema': 'http://json-schema.org/draft-07/schema#', 'type': 'object', @@ -125,16 +142,17 @@ def bbox(self): if self.camera_config is not None: if "crs" in self.camera_config: crs = self.camera_config["crs"] - transformer = Transformer.from_crs( - CRS.from_user_input(crs), - CRS.from_epsg(4326), - always_xy=True).transform + if crs: + transformer = Transformer.from_crs( + CRS.from_user_input(crs), + CRS.from_epsg(4326), + always_xy=True).transform - polygon = shapely.wkt.loads(self.camera_config["bbox"]) - polygon = shapely.ops.transform(transformer, polygon) - return GEOSGeometry(polygon.wkt, srid=4326) + polygon = shapely.wkt.loads(self.camera_config["bbox"]) + polygon = shapely.ops.transform(transformer, polygon) + return GEOSGeometry(polygon.wkt, srid=4326) - bbox.fget.short_description = "Polygon bounding box (wkt) for area of interest" + bbox.fget.short_description = "Polygon bounding box (wkt only) for area of interest" @property def x(self): @@ -160,21 +178,73 @@ def width(self): width.fget.short_description = "Width of frames [pix]" + @property + def bbox_plot_3d(self): + """ + render a 3d plot with cross section and bbox in matplotlib, + This does not use any geographic CRS; it just plots coordinates. + """ + fig = plt.figure(figsize=(7, 5)) + ax = fig.add_subplot(111, projection="3d") + # load cam config and cross section from data fields + camera_config = pyorc.CameraConfig(**self.camera_config) + cs_data, crs = pyorc.cli.cli_utils.read_shape(geojson=self.profile.data) + # coerce into a geopandas GeoDataFrame + if crs is not None: + geometry = [Point(_x, _y, _z) for _x, _y, _z in cs_data] + cs_data = gpd.GeoDataFrame(geometry=geometry, crs=crs) + cs = pyorc.CrossSection(camera_config=camera_config, cross_section=cs_data) + cs.camera_config.plot(ax=ax, mode="3d") + cs.plot(ax=ax, h=cs.camera_config.gcps["h_ref"]) + # ax.set_xlabel("x") + # ax.set_ylabel("y") + ax.set_aspect("equal", adjustable="datalim") + ax.legend(loc="best", fontsize=7) + + buf = io.BytesIO() + fig.savefig(buf, format="jpg", dpi=100, bbox_inches="tight") + plt.close(fig) + img_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return mark_safe(bbox_plot_3d_template.format(img_b64)) + + bbox_plot_3d.fget.short_description = "Camera calibration 3D view" + + @property def bbox_view(self): - if self.profile: + if self.bbox: + try: + bbox_wkt = self.bbox.wkt + except Exception: + bbox_wkt = None + else: + bbox_wkt = None + if self.profile and getattr(self.profile, "multipoint", None): + try: + profile_wkt = self.profile.multipoint.wkt + except Exception: + profile_wkt = None + else: + profile_wkt = None + if bbox_wkt and profile_wkt: return mark_safe( map_template.format(self.bbox.wkt, self.profile.multipoint.wkt, self.x, self.y) ) - return mark_safe( - map_template.format( - self.bbox.wkt, - 'MULTIPOINT EMPTY', - self.x, - self.y + elif bbox_wkt: + # only plot bounding box + return mark_safe( + map_template.format( + self.bbox.wkt, + 'MULTIPOINT EMPTY', + self.x, + self.y + ) ) - ) + else: + #fallback to simple plot + return None + bbox_view.fget.short_description = "Camera calibration geographical view" @property def resolution(self): diff --git a/api/models/profile.py b/api/models/profile.py index 514be0e..a52c34e 100644 --- a/api/models/profile.py +++ b/api/models/profile.py @@ -67,7 +67,8 @@ def coords(self): @property def crs(self): if self.data is not None: - return CRS.from_user_input(self.data["crs"]["properties"]["name"]) + if "crs" in self.data: + return CRS.from_user_input(self.data["crs"]["properties"]["name"]) @property def multipoint(self): @@ -81,7 +82,7 @@ def multipoint(self): multipoint = shapely.ops.transform(transformer, multipoint) return GEOSGeometry(multipoint.wkt, srid=4326) - multipoint.fget.short_description = "Cross section points (wkt) for profile measurements" + multipoint.fget.short_description = "Cross section points (wkt only) for profile measurements" @property def profile_view(self): diff --git a/api/serializers/__init__.py b/api/serializers/__init__.py index e266b0c..3076fba 100644 --- a/api/serializers/__init__.py +++ b/api/serializers/__init__.py @@ -1,6 +1,6 @@ from .device import DeviceSerializer from .site import SiteSerializer -from .camera_config import CameraConfigSerializer, CameraConfigCreateSerializer +from .camera_config import CameraConfigSerializer, CameraConfigCreateSerializer, CameraConfigUpdateSerializer from .profile import ProfileSerializer, ProfileCreateSerializer from .recipe import RecipeSerializer from .video import VideoSerializer diff --git a/api/serializers/camera_config.py b/api/serializers/camera_config.py index 4803620..b08c912 100644 --- a/api/serializers/camera_config.py +++ b/api/serializers/camera_config.py @@ -21,5 +21,14 @@ def validate(self, data): class CameraConfigCreateSerializer(CameraConfigSerializer): class Meta: model = CameraConfig - exclude = ("site", ) + exclude = ("site",) + +class CameraConfigUpdateSerializer(CameraConfigSerializer): + class Meta: + model = CameraConfig + exclude = ("site", "creator") + + def validate(self, data): + """Pass on the attributes as is, without user / creator / site check.""" + return data diff --git a/api/tests/test_api_camera_config.py b/api/tests/test_api_camera_config.py index f0e0ab0..46b78ec 100644 --- a/api/tests/test_api_camera_config.py +++ b/api/tests/test_api_camera_config.py @@ -187,10 +187,24 @@ def test_camera_config(self): "camera_config": json.dumps(cam_config), "profile": 1, "recipe": 1, - "nodeorc_version": "0.1.0" } ) self.assertEqual(r.status_code, status.HTTP_201_CREATED) + # also get the id of the camera config + cam_config_id = r.json()["id"] + + + # also check if we can PATCH the camera config + new_cam_config_data = { + "name": "geul_cam_patched" + } + r = client.patch( + f'/api/site/1/cameraconfig/{cam_config_id}/', + data=new_cam_config_data, + follow=True + ) + self.assertEqual(r.status_code, status.HTTP_200_OK) + # make a device with which we can test the task_form creation data = get_device_data() r = client.post( diff --git a/api/views/camera_config.py b/api/views/camera_config.py index 6910764..add86d7 100644 --- a/api/views/camera_config.py +++ b/api/views/camera_config.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from rest_framework.decorators import action -from api.serializers import CameraConfigSerializer, CameraConfigCreateSerializer, TaskFormSerializer +from api.serializers import CameraConfigSerializer, CameraConfigCreateSerializer, CameraConfigUpdateSerializer, TaskFormSerializer from api.models import CameraConfig, Device, TaskForm from api.views import BaseModelViewSet @@ -20,10 +20,13 @@ class CameraConfigViewSet(BaseModelViewSet): """ queryset = CameraConfig.objects.all().order_by('name') serializer_class = CameraConfigSerializer + http_method_names = ["get", "post", "delete", "patch"] def get_serializer_class(self): if self.action == 'create': return CameraConfigCreateSerializer + elif self.action in ['update', 'partial_update']: + return CameraConfigUpdateSerializer return CameraConfigSerializer def create(self, request, site_pk=None, *args, **kwargs): @@ -43,6 +46,8 @@ def create(self, request, site_pk=None, *args, **kwargs): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) @extend_schema( description="Create a task form for a specified device of the camera configuration", diff --git a/requirements.txt b/requirements.txt index 72e01dc..0077d22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ django-storages==1.14.4 drf-nested-routers==0.94.1 drf-spectacular==0.27.2 fontawesomefree==6.6.0 -nodeorc==0.2.1 +nodeorc==0.2.3 pytest-django==4.9.0 psycopg2-binary==2.9.9 shortuuid==1.0.13