pyobs-aravis 2.0.0.dev15__tar.gz → 2.0.0.dev16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyobs_aravis-2.0.0.dev16/.github/workflows/dependabot-automerge.yml +24 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/PKG-INFO +1 -1
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/aravis.py +52 -5
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/araviscamera.py +40 -21
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/gui.py +53 -13
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyproject.toml +1 -1
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/uv.lock +1 -1
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/dependabot.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pypi.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pytest.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/ruff.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.pre-commit-config.yaml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.readthedocs.yml +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/CHANGELOG.rst +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/LICENSE +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/README.md +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/Makefile +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/make.bat +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/requirements.txt +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/conf.py +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/index.rst +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/__init__.py +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/tests/test_araviscamera.py +0 -0
- {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/tests/test_import.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Dependabot auto-merge
|
|
2
|
+
|
|
3
|
+
on: pull_request
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: write
|
|
7
|
+
pull-requests: write
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
dependabot:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}
|
|
13
|
+
steps:
|
|
14
|
+
- name: Fetch Dependabot metadata
|
|
15
|
+
id: metadata
|
|
16
|
+
uses: dependabot/fetch-metadata@v2
|
|
17
|
+
with:
|
|
18
|
+
compat-lookup: true
|
|
19
|
+
- name: Enable auto-merge for patch and minor updates
|
|
20
|
+
if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }}
|
|
21
|
+
run: gh pr merge --auto --merge "$PR_URL"
|
|
22
|
+
env:
|
|
23
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
24
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -209,14 +209,53 @@ class Camera(object):
|
|
|
209
209
|
if not buf:
|
|
210
210
|
return None
|
|
211
211
|
pixel_format = buf.get_image_pixel_format()
|
|
212
|
+
# The pixel format value packs total bits per pixel into bits 16-23 (see
|
|
213
|
+
# ARV_PIXEL_FORMAT_BIT_PER_PIXEL) and a component count category into bits 24-31
|
|
214
|
+
# (1 = single-plane mono/Bayer, 2 = multi-component colour). Mono 10/12/14-bit depths
|
|
215
|
+
# are stored in 16-bit samples (bits_per_pixel == 16), so they need no special case here.
|
|
212
216
|
bits_per_pixel = pixel_format >> 16 & 0xFF
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
num_components = pixel_format >> 24 & 0xFF
|
|
218
|
+
height = buf.get_image_height()
|
|
219
|
+
width = buf.get_image_width()
|
|
220
|
+
|
|
221
|
+
if num_components == 1:
|
|
222
|
+
# mono or Bayer: one sample per pixel
|
|
223
|
+
if bits_per_pixel == 8:
|
|
224
|
+
INTP = ctypes.POINTER(ctypes.c_uint8)
|
|
225
|
+
elif bits_per_pixel == 16:
|
|
226
|
+
INTP = ctypes.POINTER(ctypes.c_uint16)
|
|
227
|
+
else:
|
|
228
|
+
raise AravisException(
|
|
229
|
+
"Unsupported pixel format %#x (%d bits per pixel)" % (pixel_format, bits_per_pixel)
|
|
230
|
+
)
|
|
231
|
+
shape = (height, width)
|
|
232
|
+
elif num_components == 2:
|
|
233
|
+
# colour: interleaved channels; reshape to (height, width, channels) rather than
|
|
234
|
+
# flattening every channel into a single mono array
|
|
235
|
+
if pixel_format in (Aravis.PIXEL_FORMAT_RGB_8_PACKED, Aravis.PIXEL_FORMAT_BGR_8_PACKED):
|
|
236
|
+
INTP = ctypes.POINTER(ctypes.c_uint8)
|
|
237
|
+
shape = (height, width, 3)
|
|
238
|
+
elif pixel_format in (Aravis.PIXEL_FORMAT_RGBA_8_PACKED, Aravis.PIXEL_FORMAT_BGRA_8_PACKED):
|
|
239
|
+
INTP = ctypes.POINTER(ctypes.c_uint8)
|
|
240
|
+
shape = (height, width, 4)
|
|
241
|
+
elif pixel_format in (
|
|
242
|
+
Aravis.PIXEL_FORMAT_RGB_10_PACKED,
|
|
243
|
+
Aravis.PIXEL_FORMAT_BGR_10_PACKED,
|
|
244
|
+
Aravis.PIXEL_FORMAT_RGB_12_PACKED,
|
|
245
|
+
Aravis.PIXEL_FORMAT_BGR_12_PACKED,
|
|
246
|
+
):
|
|
247
|
+
INTP = ctypes.POINTER(ctypes.c_uint16)
|
|
248
|
+
shape = (height, width, 3)
|
|
249
|
+
else:
|
|
250
|
+
raise AravisException(
|
|
251
|
+
"Unsupported pixel format %#x (%d bits per pixel)" % (pixel_format, bits_per_pixel)
|
|
252
|
+
)
|
|
215
253
|
else:
|
|
216
|
-
|
|
254
|
+
raise AravisException("Unsupported pixel format %#x (%d bits per pixel)" % (pixel_format, bits_per_pixel))
|
|
255
|
+
|
|
217
256
|
addr = buf.get_data()
|
|
218
257
|
ptr = ctypes.cast(addr, INTP)
|
|
219
|
-
im = np.ctypeslib.as_array(ptr,
|
|
258
|
+
im = np.ctypeslib.as_array(ptr, shape)
|
|
220
259
|
im = im.copy()
|
|
221
260
|
return im
|
|
222
261
|
|
|
@@ -260,11 +299,19 @@ class Camera(object):
|
|
|
260
299
|
self.cam.stop_acquisition()
|
|
261
300
|
|
|
262
301
|
def shutdown(self):
|
|
263
|
-
#
|
|
302
|
+
# Explicitly stop acquisition, then release the Aravis objects. stop_acquisition()
|
|
303
|
+
# tells the camera to stop streaming; dropping the stream/device/camera releases our
|
|
304
|
+
# GObject references so the underlying GigE socket is closed when the device is
|
|
305
|
+
# finalized. Serialized against try_pop_frame() by self._lock, so a poll in a
|
|
306
|
+
# background thread can't be mid-read while we tear the objects down.
|
|
264
307
|
with self._lock:
|
|
265
308
|
if self._closed:
|
|
266
309
|
return
|
|
267
310
|
self._closed = True
|
|
311
|
+
try:
|
|
312
|
+
self.cam.stop_acquisition()
|
|
313
|
+
except Exception:
|
|
314
|
+
self.logger.exception("Error stopping acquisition during shutdown.")
|
|
268
315
|
del self.stream
|
|
269
316
|
del self.dev
|
|
270
317
|
del self.cam
|
|
@@ -51,6 +51,11 @@ class AravisCamera(BaseVideo, IExposureTime):
|
|
|
51
51
|
self._camera: aravis.Camera | None = None
|
|
52
52
|
self._settings: dict[str, Any] = {} if settings is None else settings
|
|
53
53
|
self._camera_lock = asyncio.Lock()
|
|
54
|
+
# Serializes access to self._camera between the SDK threads spawned by _run_blocking:
|
|
55
|
+
# the background poll thread (_wait_for_frame) reads frames from the camera while
|
|
56
|
+
# _open_camera/_close_camera set/tear down the same object, and those run on other
|
|
57
|
+
# daemon threads rather than the event loop, so the async _camera_lock can't cover them.
|
|
58
|
+
self._device_lock = threading.Lock()
|
|
54
59
|
self._buffers = buffers
|
|
55
60
|
self._exposure_time: float = 0.0
|
|
56
61
|
|
|
@@ -83,25 +88,27 @@ class AravisCamera(BaseVideo, IExposureTime):
|
|
|
83
88
|
from . import aravis
|
|
84
89
|
|
|
85
90
|
log.info("Connecting to camera %s...", self._camera_device_name)
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
with self._device_lock:
|
|
92
|
+
self._camera = aravis.Camera(self._camera_device_name) # type: ignore[assignment]
|
|
93
|
+
log.info("Connected.")
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
for key, value in self._settings.items():
|
|
96
|
+
log.info("Setting value %s=%s...", key, value)
|
|
97
|
+
self._camera.set_feature(key, value) # type: ignore[union-attr]
|
|
92
98
|
|
|
93
|
-
|
|
99
|
+
self._camera.start_acquisition_continuous(nb_buffers=self._buffers) # type: ignore[union-attr]
|
|
94
100
|
|
|
95
101
|
def _close_camera(self) -> None:
|
|
96
102
|
"""Close camera."""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
with self._device_lock:
|
|
104
|
+
if self._camera is not None:
|
|
105
|
+
log.info("Closing camera...")
|
|
106
|
+
try:
|
|
107
|
+
self._camera.stop_acquisition() # type: ignore[union-attr]
|
|
108
|
+
self._camera.shutdown() # type: ignore[union-attr]
|
|
109
|
+
except Exception:
|
|
110
|
+
log.exception("Error closing camera.")
|
|
111
|
+
self._camera = None
|
|
105
112
|
|
|
106
113
|
@staticmethod
|
|
107
114
|
async def _run_blocking(func: Callable[[], None], timeout: float = _SDK_CALL_TIMEOUT) -> bool:
|
|
@@ -178,8 +185,8 @@ class AravisCamera(BaseVideo, IExposureTime):
|
|
|
178
185
|
async def _wait_for_frame(self, timeout: float = _FRAME_WAIT_TIMEOUT) -> npt.NDArray[Any] | None:
|
|
179
186
|
"""Waits for the next frame without blocking the event loop.
|
|
180
187
|
|
|
181
|
-
Polls
|
|
182
|
-
directly from the async loop with a sleep in between each attempt --
|
|
188
|
+
Polls try_pop_frame() from a background thread (see _run_blocking) rather than polling it
|
|
189
|
+
directly from the async loop with a sleep in between each attempt -- try_pop_frame() is
|
|
183
190
|
assumed non-blocking in the common case, but if the underlying aravis/GLib call ever
|
|
184
191
|
doesn't honor that (camera hiccup, network stall for GigE Vision), polling it directly
|
|
185
192
|
would freeze the whole module for as long as that lasts, repeatedly, for the module's
|
|
@@ -192,10 +199,16 @@ class AravisCamera(BaseVideo, IExposureTime):
|
|
|
192
199
|
result: list[npt.NDArray[Any]] = []
|
|
193
200
|
|
|
194
201
|
def _poll() -> None:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
#
|
|
202
|
+
while True:
|
|
203
|
+
# Hold the device lock only for the (non-blocking) try_pop_frame() call and the
|
|
204
|
+
# camera reference check, so _close_camera() running on another thread can take the
|
|
205
|
+
# lock between polls and tear the camera down without racing a mid-frame read.
|
|
206
|
+
with self._device_lock:
|
|
207
|
+
camera = self._camera
|
|
208
|
+
if camera is None:
|
|
209
|
+
return
|
|
210
|
+
frame = camera.try_pop_frame() # type: ignore[union-attr]
|
|
211
|
+
# try_pop_frame() can return a non-None array that's empty along axis 0 instead of
|
|
199
212
|
# None -- treat that the same as "not ready yet" rather than a real frame
|
|
200
213
|
if frame is not None and frame.size != 0: # type: ignore[union-attr]
|
|
201
214
|
result.append(frame) # type: ignore[arg-type]
|
|
@@ -214,7 +227,13 @@ class AravisCamera(BaseVideo, IExposureTime):
|
|
|
214
227
|
exposure_time: Exposure time in seconds.
|
|
215
228
|
"""
|
|
216
229
|
await self.activate_camera()
|
|
217
|
-
|
|
230
|
+
|
|
231
|
+
def _set() -> None:
|
|
232
|
+
# take the device lock so this can't race _close_camera() tearing down self._camera
|
|
233
|
+
with self._device_lock:
|
|
234
|
+
self._camera.set_exposure_time(exposure_time * 1e6) # type: ignore[union-attr]
|
|
235
|
+
|
|
236
|
+
await self._run_blocking(_set)
|
|
218
237
|
self._exposure_time = exposure_time
|
|
219
238
|
await self.comm.set_state(IExposureTime, ExposureTimeState(exposure_time=exposure_time))
|
|
220
239
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import sys
|
|
3
|
-
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, TypeVar
|
|
4
6
|
|
|
5
7
|
import qasync # type: ignore
|
|
6
8
|
from astropy.io import fits
|
|
@@ -10,15 +12,38 @@ from PySide6 import QtWidgets # type: ignore[import-untyped]
|
|
|
10
12
|
|
|
11
13
|
from . import aravis
|
|
12
14
|
|
|
15
|
+
_T = TypeVar("_T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _run_blocking(func: Callable[[], _T]) -> _T:
|
|
19
|
+
"""Run a blocking aravis/GLib call in a daemon thread, mirroring AravisCamera._run_blocking.
|
|
20
|
+
|
|
21
|
+
The default ThreadPoolExecutor (what run_in_executor(None, ...) uses) is deliberately
|
|
22
|
+
avoided here: its worker threads are non-daemon, so a hung GigE call would block interpreter
|
|
23
|
+
shutdown instead of just this call. A fresh daemon thread per call has the same drawback the
|
|
24
|
+
module accepts for the same reason.
|
|
25
|
+
"""
|
|
26
|
+
loop = asyncio.get_running_loop()
|
|
27
|
+
future: asyncio.Future[_T] = loop.create_future()
|
|
28
|
+
|
|
29
|
+
def _wrapper() -> None:
|
|
30
|
+
try:
|
|
31
|
+
result = func()
|
|
32
|
+
except BaseException as exc:
|
|
33
|
+
loop.call_soon_threadsafe(future.set_exception, exc)
|
|
34
|
+
else:
|
|
35
|
+
loop.call_soon_threadsafe(future.set_result, result)
|
|
36
|
+
|
|
37
|
+
threading.Thread(target=_wrapper, daemon=True).start()
|
|
38
|
+
return await future
|
|
39
|
+
|
|
13
40
|
|
|
14
41
|
class MainWindow(QtWidgets.QMainWindow):
|
|
15
42
|
def __init__(self, device: str) -> None:
|
|
16
43
|
super().__init__()
|
|
17
44
|
self.setWindowTitle(f"Aravis Camera — {device}")
|
|
18
45
|
|
|
19
|
-
self.camera
|
|
20
|
-
self.camera.start_acquisition_continuous()
|
|
21
|
-
|
|
46
|
+
self.camera: aravis.Camera | None = None
|
|
22
47
|
self._last_frame: NDArray[Any] | None = None
|
|
23
48
|
self._preview_task: asyncio.Task[None] | None = None
|
|
24
49
|
|
|
@@ -29,7 +54,6 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
29
54
|
controls = QtWidgets.QGroupBox("Camera")
|
|
30
55
|
controls_layout = QtWidgets.QVBoxLayout(controls)
|
|
31
56
|
self.exposure_time = ExposureTimeWidget()
|
|
32
|
-
self.exposure_time.spin_exposure_time.setValue(self.camera.get_exposure_time() / 1e6)
|
|
33
57
|
self.exposure_time.exposure_time_changed.connect(self._exposure_time_changed)
|
|
34
58
|
controls_layout.addWidget(self.exposure_time)
|
|
35
59
|
self.expose = ExposeWidget(can_abort_exposure=False)
|
|
@@ -42,33 +66,49 @@ class MainWindow(QtWidgets.QMainWindow):
|
|
|
42
66
|
|
|
43
67
|
self.expose.expose_clicked.connect(self._expose_clicked)
|
|
44
68
|
|
|
69
|
+
self._init_task = asyncio.ensure_future(self._init_camera(device))
|
|
70
|
+
|
|
71
|
+
async def _init_camera(self, device: str) -> None:
|
|
72
|
+
# Camera construction + start_acquisition_continuous are blocking (GigE discovery and
|
|
73
|
+
# handshake can take seconds), so run them off the Qt thread rather than in __init__.
|
|
74
|
+
self.camera = await _run_blocking(lambda: aravis.Camera(device))
|
|
75
|
+
await _run_blocking(self.camera.start_acquisition_continuous)
|
|
76
|
+
exposure_time = await _run_blocking(self.camera.get_exposure_time)
|
|
77
|
+
self.exposure_time.spin_exposure_time.setValue(exposure_time / 1e6)
|
|
45
78
|
self._preview_task = asyncio.ensure_future(self._live_preview())
|
|
46
79
|
|
|
47
|
-
|
|
48
|
-
|
|
80
|
+
@qasync.asyncSlot(float) # type: ignore
|
|
81
|
+
async def _exposure_time_changed(self, value: float) -> None:
|
|
82
|
+
if self.camera is not None:
|
|
83
|
+
await _run_blocking(lambda: self.camera.set_exposure_time(value * 1e6))
|
|
49
84
|
|
|
50
85
|
async def _live_preview(self) -> None:
|
|
51
|
-
loop = asyncio.get_running_loop()
|
|
52
86
|
while True:
|
|
53
|
-
self.
|
|
87
|
+
if self.camera is None:
|
|
88
|
+
await asyncio.sleep(0.05)
|
|
89
|
+
continue
|
|
90
|
+
self._last_frame = await _run_blocking(self.camera.pop_frame)
|
|
54
91
|
self.display.set_data(fits.PrimaryHDU(self._last_frame))
|
|
55
92
|
await asyncio.sleep(0.05)
|
|
56
93
|
|
|
57
94
|
@qasync.asyncSlot(int) # type: ignore
|
|
58
95
|
async def _expose_clicked(self, count: int) -> None:
|
|
96
|
+
if self.camera is None:
|
|
97
|
+
return
|
|
59
98
|
self.expose.start_exposure(self.exposure_time.value)
|
|
60
|
-
loop = asyncio.get_running_loop()
|
|
61
99
|
for _ in range(count):
|
|
62
|
-
self._last_frame = await
|
|
100
|
+
self._last_frame = await _run_blocking(self.camera.pop_frame)
|
|
63
101
|
if self._last_frame is not None:
|
|
64
102
|
self.display.set_data(fits.PrimaryHDU(self._last_frame))
|
|
65
103
|
self.expose.set_exposures_left()
|
|
66
104
|
|
|
67
105
|
def closeEvent(self, event: Any) -> None:
|
|
106
|
+
self._init_task.cancel()
|
|
68
107
|
if self._preview_task is not None:
|
|
69
108
|
self._preview_task.cancel()
|
|
70
|
-
self.camera
|
|
71
|
-
|
|
109
|
+
if self.camera is not None:
|
|
110
|
+
self.camera.stop_acquisition()
|
|
111
|
+
self.camera.shutdown()
|
|
72
112
|
super().closeEvent(event)
|
|
73
113
|
|
|
74
114
|
|
|
@@ -2320,7 +2320,7 @@ wheels = [
|
|
|
2320
2320
|
|
|
2321
2321
|
[[package]]
|
|
2322
2322
|
name = "pyobs-aravis"
|
|
2323
|
-
version = "2.0.0.
|
|
2323
|
+
version = "2.0.0.dev16"
|
|
2324
2324
|
source = { editable = "." }
|
|
2325
2325
|
dependencies = [
|
|
2326
2326
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|