Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<site_pk>/cameraconfig/<pk> was not working, now properly functions.
### Security


## [0.2.2] - 2025-10-02
### Added
### Changed
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \
Expand Down
2 changes: 1 addition & 1 deletion LiveORC/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions api/admin/camera_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ class Media:
"width",
"resolution",
"window_size",
"bounding_box_view"
"bounding_box_view",
"bounding_box_3d_view"
]}
)
]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

1 change: 1 addition & 0 deletions api/callback_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=([
Expand Down
102 changes: 86 additions & 16 deletions api/models/camera_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = """
<div id="id_geom_div_map" class="dj_map_wrapper">
Expand Down Expand Up @@ -55,6 +64,14 @@

"""

# Simple HTML template for non‑geographic x/y plot
bbox_plot_3d_template = """
<div class="bbox-plot-wrapper">
<img src="data:image/png;base64,{}" alt="Bounding box and profile plot" />
</div>

"""

lens_position_schema = {
'schema': 'http://json-schema.org/draft-07/schema#',
'type': 'object',
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions api/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion api/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 10 additions & 1 deletion api/serializers/camera_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 15 additions & 1 deletion api/tests/test_api_camera_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion api/views/camera_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading