[libcamera-devel] [RFC 4/4] libcamera python bindings

Tomi Valkeinen tomi.valkeinen at iki.fi
Fri Sep 18 17:20:19 CEST 2020


Main issues:

- Memory management in general. Who owns the object, how to pass
  ownership, etc.

- Specifically, Request is currently broken. We can't, afaik, pass
  ownership around. So currently Python never frees a Request, and if
  the Request is not given to Camera::queueRequest, it will leak.

- The forced threading causes some headache. Need to take care to use
  gil_scoped_release when C++ context can invoke callbacks, and
  gil_scoped_acquire at the invoke wrapper.

- Callbacks. Difficult to attach context to the callbacks. I solved it
  with BoundMethodFunction and using lambda captures

- Need public Camera destructor. It is not clear to me why C++ allows it
  to be private, but pybind11 doesn't.

Signed-off-by: Tomi Valkeinen <tomi.valkeinen at iki.fi>
---
 meson.build                    |   1 +
 meson_options.txt              |   2 +
 py/meson.build                 |   1 +
 py/pycamera/__init__.py        |  29 ++++++
 py/pycamera/meson.build        |  35 +++++++
 py/pycamera/pymain.cpp         | 169 +++++++++++++++++++++++++++++++
 py/test/run-valgrind.sh        |   3 +
 py/test/run.sh                 |   3 +
 py/test/test.py                | 177 +++++++++++++++++++++++++++++++++
 py/test/valgrind-pycamera.supp |  17 ++++
 10 files changed, 437 insertions(+)
 create mode 100644 py/meson.build
 create mode 100644 py/pycamera/__init__.py
 create mode 100644 py/pycamera/meson.build
 create mode 100644 py/pycamera/pymain.cpp
 create mode 100755 py/test/run-valgrind.sh
 create mode 100755 py/test/run.sh
 create mode 100755 py/test/test.py
 create mode 100644 py/test/valgrind-pycamera.supp

diff --git a/meson.build b/meson.build
index c58d458..3d1c797 100644
--- a/meson.build
+++ b/meson.build
@@ -104,6 +104,7 @@ libcamera_includes = include_directories('include')
 subdir('include')
 subdir('src')
 subdir('utils')
+subdir('py')
 
 # The documentation and test components are optional and can be disabled
 # through configuration values. They are enabled by default.
diff --git a/meson_options.txt b/meson_options.txt
index d2e07ef..45b88b6 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -32,3 +32,5 @@ option('v4l2',
         type : 'boolean',
         value : false,
         description : 'Compile the V4L2 compatibility layer')
+
+option('pycamera', type : 'feature', value : 'auto')
diff --git a/py/meson.build b/py/meson.build
new file mode 100644
index 0000000..42ffa22
--- /dev/null
+++ b/py/meson.build
@@ -0,0 +1 @@
+subdir('pycamera')
diff --git a/py/pycamera/__init__.py b/py/pycamera/__init__.py
new file mode 100644
index 0000000..c37571b
--- /dev/null
+++ b/py/pycamera/__init__.py
@@ -0,0 +1,29 @@
+from .pycamera import *
+from enum import Enum
+import os
+import struct
+import mmap
+
+# Add a wrapper which returns an array of Cameras, which have keep-alive to the CameraManager
+def __CameraManager__cameras(self):
+	cameras = []
+	for i in range(self.num_cameras):
+		cameras.append(self.at(i))
+	return cameras
+
+
+CameraManager.cameras = property(__CameraManager__cameras)
+
+# Add a wrapper which returns an array of buffers, which have keep-alive to the FB allocator
+def __FrameBufferAllocator__buffers(self, stream):
+	buffers = []
+	for i in range(self.num_buffers(stream)):
+		buffers.append(self.at(stream, i))
+	return buffers
+
+FrameBufferAllocator.buffers = __FrameBufferAllocator__buffers
+
+def __FrameBuffer__mmap(self, plane):
+	return mmap.mmap(self.fd(plane), self.length(plane), mmap.MAP_SHARED, mmap.PROT_READ)
+
+FrameBuffer.mmap = __FrameBuffer__mmap
diff --git a/py/pycamera/meson.build b/py/pycamera/meson.build
new file mode 100644
index 0000000..50bdfb8
--- /dev/null
+++ b/py/pycamera/meson.build
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: CC0-1.0
+
+py3_dep = dependency('python3', required : get_option('pycamera'))
+
+if py3_dep.found() == false
+    subdir_done()
+endif
+
+pycamera_sources = files([
+    'pymain.cpp',
+])
+
+pycamera_deps = [
+    libcamera_dep,
+    py3_dep,
+]
+
+includes = [
+    '../../ext/pybind11/include',
+]
+
+destdir = get_option('libdir') + '/python' + py3_dep.version() + '/site-packages/pycamera'
+
+pycamera = shared_module('pycamera',
+                         pycamera_sources,
+                         install : true,
+                         install_dir : destdir,
+                         name_prefix : '',
+                         include_directories : includes,
+                         dependencies : pycamera_deps)
+
+# Copy __init__.py to build dir so that we can run without installing
+configure_file(input: '__init__.py', output: '__init__.py', copy: true)
+
+install_data(['__init__.py'], install_dir: destdir)
diff --git a/py/pycamera/pymain.cpp b/py/pycamera/pymain.cpp
new file mode 100644
index 0000000..569423a
--- /dev/null
+++ b/py/pycamera/pymain.cpp
@@ -0,0 +1,169 @@
+#include <chrono>
+#include <thread>
+#include <fcntl.h>
+#include <unistd.h>
+#include <sys/mman.h>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+#include <pybind11/stl_bind.h>
+#include <pybind11/functional.h>
+
+#include <libcamera/libcamera.h>
+
+namespace py = pybind11;
+
+using namespace std;
+using namespace libcamera;
+
+PYBIND11_MODULE(pycamera, m) {
+	m.def("sleep", [](double s) {
+		py::gil_scoped_release release;
+		this_thread::sleep_for(std::chrono::duration<double>(s));
+	});
+
+	py::class_<CameraManager>(m, "CameraManager")
+			// Call cm->start implicitly, as we can't use stop() either
+			.def(py::init([]() {
+				auto cm = make_unique<CameraManager>();
+				cm->start();
+				return cm;
+			}))
+
+			//.def("start", &CameraManager::start)
+
+			// stop() cannot be called, as CameraManager expects all Camera instances to be released before calling stop
+			// and we can't have such requirement in python, especially as we have a keep-alive from Camera to CameraManager.
+			// So we rely on GC and the keep-alives.
+			//.def("stop", &CameraManager::stop)
+
+			.def_property_readonly("num_cameras", [](CameraManager& cm) { return cm.cameras().size(); })
+			.def("at", [](CameraManager& cm, unsigned int idx) { return cm.cameras()[idx]; }, py::keep_alive<0, 1>())
+	;
+
+	py::class_<Camera, shared_ptr<Camera>>(m, "Camera")
+			.def_property_readonly("id", &Camera::id)
+			.def("acquire", &Camera::acquire)
+			.def("release", &Camera::release)
+			.def("start", &Camera::start)
+			.def("stop", [](shared_ptr<Camera>& self) {
+				// Camera::stop can cause callbacks to be invoked, so we must release GIL
+				py::gil_scoped_release release;
+				self->stop();
+			})
+			.def("generateConfiguration", &Camera::generateConfiguration)
+			.def("configure", &Camera::configure)
+
+			// XXX created requests MUST be queued to be freed, python will not free them
+			.def("createRequest", &Camera::createRequest, py::arg("cookie") = 0, py::return_value_policy::reference_internal)
+			.def("queueRequest", &Camera::queueRequest)
+
+			.def_property("requestCompleted",
+				      nullptr,
+				      [](shared_ptr<Camera>& self, function<void(Request*)> f) {
+						if (f) {
+							self->requestCompleted.connect(function<void(Request*)>([f = move(f)](Request* req) {
+								// Called from libcamera's internal thread, so need to get GIL
+								py::gil_scoped_acquire acquire;
+								f(req);
+							}));
+						} else {
+							// XXX Disconnects all, as we have no means to disconnect the specific std::function
+							self->requestCompleted.disconnect();
+						}
+					}
+			)
+
+			.def_property("bufferCompleted",
+				      nullptr,
+				      [](shared_ptr<Camera>& self, function<void(Request*, FrameBuffer*)> f) {
+						if (f) {
+							self->bufferCompleted.connect(function<void(Request*, FrameBuffer* fb)>([f = move(f)](Request* req, FrameBuffer* fb) {
+								// Called from libcamera's internal thread, so need to get GIL
+								py::gil_scoped_acquire acquire;
+								f(req, fb);
+							}));
+						} else {
+							// XXX Disconnects all, as we have no means to disconnect the specific std::function
+							self->bufferCompleted.disconnect();
+						}
+					}
+			)
+
+			;
+
+	py::class_<CameraConfiguration>(m, "CameraConfiguration")
+			.def("at", (StreamConfiguration& (CameraConfiguration::*)(unsigned int))&CameraConfiguration::at,
+			     py::return_value_policy::reference_internal)
+			.def("validate", &CameraConfiguration::validate)
+			.def_property_readonly("size", &CameraConfiguration::size)
+			.def_property_readonly("empty", &CameraConfiguration::empty)
+			;
+
+	py::class_<StreamConfiguration>(m, "StreamConfiguration")
+			.def("toString", &StreamConfiguration::toString)
+			.def_property_readonly("stream", &StreamConfiguration::stream,
+			     py::return_value_policy::reference_internal)
+			.def_property("width",
+				[](StreamConfiguration& c) { return c.size.width; },
+				[](StreamConfiguration& c, unsigned int w) { c.size.width = w; }
+			)
+			.def_property("height",
+				[](StreamConfiguration& c) { return c.size.height; },
+				[](StreamConfiguration& c, unsigned int h) { c.size.height = h; }
+			)
+			.def_property("fmt",
+				[](StreamConfiguration& c) { return c.pixelFormat.toString(); },
+				[](StreamConfiguration& c, string fmt) { c.pixelFormat = PixelFormat::fromString(fmt); }
+			)
+			;
+
+	py::enum_<StreamRole>(m, "StreamRole")
+			.value("StillCapture", StreamRole::StillCapture)
+			.value("StillCaptureRaw", StreamRole::StillCaptureRaw)
+			.value("VideoRecording", StreamRole::VideoRecording)
+			.value("Viewfinder", StreamRole::Viewfinder)
+			;
+
+	py::class_<FrameBufferAllocator>(m, "FrameBufferAllocator")
+			.def(py::init<shared_ptr<Camera>>(), py::keep_alive<1, 2>())
+			.def("allocate", &FrameBufferAllocator::allocate)
+			.def("free", &FrameBufferAllocator::free)
+			.def("num_buffers", [](FrameBufferAllocator& fa, Stream* stream) { return fa.buffers(stream).size(); })
+			.def("at", [](FrameBufferAllocator& fa, Stream* stream, unsigned int idx) { return fa.buffers(stream).at(idx).get(); },
+				py::return_value_policy::reference_internal)
+			;
+
+	py::class_<FrameBuffer, unique_ptr<FrameBuffer, py::nodelete>>(m, "FrameBuffer")
+			.def_property_readonly("metadata", &FrameBuffer::metadata, py::return_value_policy::reference_internal)
+			.def("length", [](FrameBuffer& fb, uint32_t idx) {
+				const FrameBuffer::Plane &plane = fb.planes()[idx];
+				return plane.length;
+			})
+			.def("fd", [](FrameBuffer& fb, uint32_t idx) {
+				const FrameBuffer::Plane &plane = fb.planes()[idx];
+				return plane.fd.fd();
+			})
+			;
+
+	py::class_<Stream, unique_ptr<Stream, py::nodelete>>(m, "Stream")
+			;
+
+	py::class_<Request, unique_ptr<Request, py::nodelete>>(m, "Request")
+			.def("addBuffer", &Request::addBuffer)
+			.def_property_readonly("status", &Request::status)
+			.def_property_readonly("buffers", &Request::buffers)
+			;
+
+
+	py::enum_<Request::Status>(m, "RequestStatus")
+			.value("Pending", Request::RequestPending)
+			.value("Complete", Request::RequestComplete)
+			.value("Cancelled", Request::RequestCancelled)
+			;
+
+	py::class_<FrameMetadata>(m, "FrameMetadata")
+			.def_property_readonly("sequence", [](FrameMetadata& data) { return data.sequence; })
+			.def("bytesused", [](FrameMetadata& data, uint32_t idx) { return data.planes[idx].bytesused; })
+			;
+}
diff --git a/py/test/run-valgrind.sh b/py/test/run-valgrind.sh
new file mode 100755
index 0000000..623188e
--- /dev/null
+++ b/py/test/run-valgrind.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+PYTHONMALLOC=malloc PYTHONPATH=../../build/debug/py valgrind --suppressions=valgrind-pycamera.supp --leak-check=full --show-leak-kinds=definite --gen-suppressions=yes python3 test.py $*
diff --git a/py/test/run.sh b/py/test/run.sh
new file mode 100755
index 0000000..035d3ea
--- /dev/null
+++ b/py/test/run.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+PYTHONPATH=../../build/debug/py python3 test.py $*
diff --git a/py/test/test.py b/py/test/test.py
new file mode 100755
index 0000000..0f874d3
--- /dev/null
+++ b/py/test/test.py
@@ -0,0 +1,177 @@
+#!/usr/bin/python3
+
+import pycamera as pycam
+import time
+import binascii
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-n", "--num-frames", type=int, default=10)
+parser.add_argument("-c", "--print-crc", action="store_true")
+parser.add_argument("-s", "--save-frames", action="store_true")
+parser.add_argument("-m", "--max-cameras", type=int)
+args = parser.parse_args()
+
+cm = pycam.CameraManager()
+
+cameras = cm.cameras
+
+if len(cameras) == 0:
+	print("No cameras")
+	exit(0)
+
+print("Cameras:")
+for c in cameras:
+	print("    {}".format(c.id))
+
+contexts = []
+
+for i in range(len(cameras)):
+	contexts.append({ "camera": cameras[i], "id": i })
+	if args.max_cameras and args.max_cameras - 1 == i:
+		break
+
+for ctx in contexts:
+	ctx["camera"].acquire()
+
+def configure_camera(ctx):
+	camera = ctx["camera"]
+
+	# Configure
+
+	config = camera.generateConfiguration([pycam.StreamRole.Viewfinder])
+	stream_config = config.at(0)
+
+	#stream_config.width = 160;
+	#stream_config.height = 120;
+	#stream_config.fmt = "YUYV"
+
+	print("Cam {}: stream config {}".format(ctx["id"], stream_config.toString()))
+
+	camera.configure(config);
+
+	# Allocate buffers
+
+	stream = stream_config.stream
+
+	allocator = pycam.FrameBufferAllocator(camera);
+	ret = allocator.allocate(stream)
+	if ret < 0:
+		print("Can't allocate buffers")
+		exit(-1)
+
+	allocated = allocator.num_buffers(stream)
+	print("Cam {}: Allocated {} buffers for stream".format(ctx["id"], allocated))
+
+	# Create Requests
+
+	requests = []
+	buffers = allocator.buffers(stream)
+
+	for buffer in buffers:
+		request = camera.createRequest()
+		if request == None:
+			print("Can't create request")
+			exit(-1)
+
+		ret = request.addBuffer(stream, buffer)
+		if ret < 0:
+			print("Can't set buffer for request")
+			exit(-1)
+
+		requests.append(request)
+
+	ctx["allocator"] = allocator
+	ctx["requests"] = requests
+
+
+def buffer_complete_cb(ctx, req, fb):
+	print("Cam {}: Buf {} Complete: {}".format(ctx["id"], ctx["bufs_completed"], req.status))
+
+	with fb.mmap(0) as b:
+		if args.print_crc:
+			crc = binascii.crc32(b)
+			print("Cam {}:    CRC {:#x}".format(ctx["id"], crc))
+
+		if args.save_frames:
+			id = ctx["id"]
+			num = ctx["bufs_completed"]
+			filename = "frame-{}-{}.data".format(id, num)
+			with open(filename, "wb") as f:
+				f.write(b)
+			print("Cam {}:    Saved {}".format(ctx["id"], filename))
+
+	ctx["bufs_completed"] += 1
+
+def req_complete_cb(ctx, req):
+	camera = ctx["camera"]
+
+	print("Cam {}: Req {} Complete: {}".format(ctx["id"], ctx["reqs_completed"], req.status))
+
+	bufs = req.buffers
+	for stream, fb in bufs.items():
+		meta = fb.metadata
+		print("Cam {}: Buf seq {}, bytes {}".format(ctx["id"], meta.sequence, meta.bytesused(0)))
+
+	ctx["reqs_completed"] += 1
+
+	if ctx["reqs_queued"] < args.num_frames:
+		request = camera.createRequest()
+		if request == None:
+			print("Can't create request")
+			exit(-1)
+
+		for stream, fb in bufs.items():
+			ret = request.addBuffer(stream, fb)
+			if ret < 0:
+				print("Can't set buffer for request")
+				exit(-1)
+
+		camera.queueRequest(request)
+		ctx["reqs_queued"] += 1
+
+
+def setup_callbacks(ctx):
+	camera = ctx["camera"]
+
+	ctx["reqs_queued"] = 0
+	ctx["reqs_completed"] = 0
+	ctx["bufs_completed"] = 0
+
+	camera.requestCompleted = lambda req, ctx = ctx: req_complete_cb(ctx, req)
+	camera.bufferCompleted = lambda req, fb, ctx = ctx: buffer_complete_cb(ctx, req, fb)
+
+def queue_requests(ctx):
+	camera = ctx["camera"]
+	requests = ctx["requests"]
+
+	camera.start()
+
+	for request in requests:
+		camera.queueRequest(request)
+		ctx["reqs_queued"] += 1
+
+
+
+for ctx in contexts:
+	configure_camera(ctx)
+	setup_callbacks(ctx)
+
+for ctx in contexts:
+	queue_requests(ctx)
+
+
+print("Processing...")
+
+# Need to release GIL here, so that callbacks can be called
+while any(ctx["reqs_completed"] < args.num_frames for ctx in contexts):
+	pycam.sleep(0.1)
+
+print("Exiting...")
+
+for ctx in contexts:
+	camera = ctx["camera"]
+	camera.stop()
+	camera.release()
+
+print("Done")
diff --git a/py/test/valgrind-pycamera.supp b/py/test/valgrind-pycamera.supp
new file mode 100644
index 0000000..98c693f
--- /dev/null
+++ b/py/test/valgrind-pycamera.supp
@@ -0,0 +1,17 @@
+{
+   <insert_a_suppression_name_here>
+   Memcheck:Leak
+   match-leak-kinds: definite
+   fun:_Znwm
+   fun:_ZN8pybind116moduleC1EPKcS2_
+   fun:PyInit_pycamera
+   fun:_PyImport_LoadDynamicModuleWithSpec
+   obj:/usr/bin/python3.8
+   obj:/usr/bin/python3.8
+   fun:PyVectorcall_Call
+   fun:_PyEval_EvalFrameDefault
+   fun:_PyEval_EvalCodeWithName
+   fun:_PyFunction_Vectorcall
+   fun:_PyEval_EvalFrameDefault
+   fun:_PyFunction_Vectorcall
+}
-- 
Texas Instruments Finland Oy, Porkkalankatu 22, 00180 Helsinki.
Y-tunnus/Business ID: 0615521-4. Kotipaikka/Domicile: Helsinki



More information about the libcamera-devel mailing list