From ee817b53d5ae35ea442477e2b97c99bf9eb162da Mon Sep 17 00:00:00 2001 From: hcwinsemius Date: Fri, 14 Nov 2025 17:11:10 +0100 Subject: [PATCH 1/5] fix #112 --- LiveORC/settings.py | 2 +- api/serializers/__init__.py | 2 +- api/serializers/camera_config.py | 11 ++++++++++- api/tests/test_api_camera_config.py | 16 +++++++++++++++- api/views/camera_config.py | 7 ++++++- 5 files changed, 33 insertions(+), 5 deletions(-) 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/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", From 986131295ea3e6f9f58263c4273a256574ba8e78 Mon Sep 17 00:00:00 2001 From: hcwinsemius Date: Sat, 15 Nov 2025 10:56:49 +0100 Subject: [PATCH 2/5] matplotlib fallback for cam configs without CRS WIP #113 --- api/models/camera_config.py | 65 ++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/api/models/camera_config.py b/api/models/camera_config.py index f79c084..2c2640e 100644 --- a/api/models/camera_config.py +++ b/api/models/camera_config.py @@ -1,4 +1,7 @@ from datetime import timedelta +import base64 +import io +import matplotlib.pyplot as plt import shapely.wkt from django.contrib.auth import get_user_model from django.contrib.gis.db import models @@ -55,6 +58,13 @@ """ +# Simple HTML template for non‑geographic x/y plot +_bbox_plot_template = """ +
+ Bounding box and profile plot +
+""" + lens_position_schema = { 'schema': 'http://json-schema.org/draft-07/schema#', 'type': 'object', @@ -160,20 +170,59 @@ def width(self): width.fget.short_description = "Width of frames [pix]" + def _bbox_plot_fallback(self): + """ + Fallback: render a simple x/y plot with cross section and bbox in matplotlib, + This does not use any geographic CRS; it just plots coordinates. + """ + fig = plt.figure(figsize=(10, 7)) + ax = fig.add_subplot(111, projection="3d") + # load cam config and cross section from data fields + camera_config = pyorc.load_camera_config(self.data) + cs = pyorc.CrossSection(camera_config=camera_config, cs=self.profile.data) + camera_config.plot(ax=ax, mode="3d") + cs.plot(ax=ax, mode="3d") + # ax.set_xlabel("x") + # ax.set_ylabel("y") + ax.set_aspect("equal", adjustable="datalim") + ax.legend(loc="best") + + buf = io.BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + plt.close(fig) + img_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return _bbox_plot_template.format(img_b64) + + @property def bbox_view(self): - if self.profile: + if self.bbox: + try: + bbox_wkt = self.bbox.wkt + except Exception: + bbox_wkt = None + if self.profile and getattr(self.profile, "multipoint", None): + try: + profile_wkt = self.profile.multipoint.wkt + except Exception: + 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 mark_safe(self._bbox_plot_fallback()) @property From 201d0aa5161c1f2e41f6909f5ea978f9442d5ab5 Mon Sep 17 00:00:00 2001 From: hcwinsemius Date: Mon, 17 Nov 2025 16:28:48 +0100 Subject: [PATCH 3/5] added 3d view plot #113 --- Dockerfile | 2 ++ api/admin/camera_config.py | 8 +++-- api/models/camera_config.py | 61 +++++++++++++++++++++++++------------ api/models/profile.py | 5 +-- requirements.txt | 2 +- 5 files changed, 53 insertions(+), 25 deletions(-) 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/api/admin/camera_config.py b/api/admin/camera_config.py index 276182f..b2d50b8 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") @@ -231,3 +232,6 @@ def save_model(self, request, obj, form, change): def bounding_box_view(self, obj): return obj.bbox_view + def bounding_box_3d_view(self, obj): + return obj.bbox_plot_3d + diff --git a/api/models/camera_config.py b/api/models/camera_config.py index 2c2640e..4e3c6b9 100644 --- a/api/models/camera_config.py +++ b/api/models/camera_config.py @@ -1,8 +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 @@ -12,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 = """
@@ -59,10 +65,11 @@ """ # Simple HTML template for non‑geographic x/y plot -_bbox_plot_template = """ +bbox_plot_3d_template = """
Bounding box and profile plot
+ """ lens_position_schema = { @@ -135,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): @@ -170,28 +178,36 @@ def width(self): width.fget.short_description = "Width of frames [pix]" - def _bbox_plot_fallback(self): + @property + def bbox_plot_3d(self): """ - Fallback: render a simple x/y plot with cross section and bbox in matplotlib, + 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=(10, 7)) + 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.load_camera_config(self.data) - cs = pyorc.CrossSection(camera_config=camera_config, cs=self.profile.data) - camera_config.plot(ax=ax, mode="3d") - cs.plot(ax=ax, mode="3d") + 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") + ax.legend(loc="best", fontsize=7) buf = io.BytesIO() - fig.savefig(buf, format="png", bbox_inches="tight") + fig.savefig(buf, format="jpg", dpi=100, bbox_inches="tight") plt.close(fig) img_b64 = base64.b64encode(buf.getvalue()).decode("ascii") - return _bbox_plot_template.format(img_b64) + return mark_safe(bbox_plot_3d_template.format(img_b64)) + + bbox_plot_3d.fget.short_description = "Camera calibration 3D view" @property @@ -201,11 +217,15 @@ def bbox_view(self): 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) @@ -222,8 +242,9 @@ def bbox_view(self): ) else: #fallback to simple plot - return mark_safe(self._bbox_plot_fallback()) + 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/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 From 2ece3ae0628b8cc6d758ae69dc926e10c0cc62ee Mon Sep 17 00:00:00 2001 From: hcwinsemius Date: Mon, 17 Nov 2025 17:03:51 +0100 Subject: [PATCH 4/5] fix test #113 --- api/admin/camera_config.py | 4 ++++ api/callback_utils.py | 1 + 2 files changed, 5 insertions(+) diff --git a/api/admin/camera_config.py b/api/admin/camera_config.py index b2d50b8..1d122bf 100644 --- a/api/admin/camera_config.py +++ b/api/admin/camera_config.py @@ -229,9 +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=([ From a66e04e88a4208d638625a793680dde1b77e009e Mon Sep 17 00:00:00 2001 From: hcwinsemius Date: Mon, 17 Nov 2025 17:13:22 +0100 Subject: [PATCH 5/5] changelog #113 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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