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.
Files changed (26) hide show
  1. pyobs_aravis-2.0.0.dev16/.github/workflows/dependabot-automerge.yml +24 -0
  2. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/PKG-INFO +1 -1
  3. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/aravis.py +52 -5
  4. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/araviscamera.py +40 -21
  5. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/gui.py +53 -13
  6. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyproject.toml +1 -1
  7. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/uv.lock +1 -1
  8. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/dependabot.yml +0 -0
  9. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pypi.yml +0 -0
  10. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pyrefly.yml +0 -0
  11. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/pytest.yml +0 -0
  12. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.github/workflows/ruff.yml +0 -0
  13. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.pre-commit-config.yaml +0 -0
  14. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/.readthedocs.yml +0 -0
  15. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/CHANGELOG.rst +0 -0
  16. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/LICENSE +0 -0
  17. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/README.md +0 -0
  18. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/Makefile +0 -0
  19. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/make.bat +0 -0
  20. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/requirements.txt +0 -0
  21. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/_static/pyobs.gif +0 -0
  22. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/conf.py +0 -0
  23. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/docs/source/index.rst +0 -0
  24. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/pyobs_aravis/__init__.py +0 -0
  25. {pyobs_aravis-2.0.0.dev15 → pyobs_aravis-2.0.0.dev16}/tests/test_araviscamera.py +0 -0
  26. {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 }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-aravis
3
- Version: 2.0.0.dev15
3
+ Version: 2.0.0.dev16
4
4
  Summary: pyobs module for Aravis cameras
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -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
- if bits_per_pixel == 8:
214
- INTP = ctypes.POINTER(ctypes.c_uint8)
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
- INTP = ctypes.POINTER(ctypes.c_uint16)
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, (buf.get_image_height(), buf.get_image_width()))
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
- # Delete the objects on shutdown: socket will be closed!
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
- self._camera = aravis.Camera(self._camera_device_name) # type: ignore[assignment]
87
- log.info("Connected.")
91
+ with self._device_lock:
92
+ self._camera = aravis.Camera(self._camera_device_name) # type: ignore[assignment]
93
+ log.info("Connected.")
88
94
 
89
- for key, value in self._settings.items():
90
- log.info("Setting value %s=%s...", key, value)
91
- self._camera.set_feature(key, value) # type: ignore[union-attr]
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
- self._camera.start_acquisition_continuous(nb_buffers=self._buffers) # type: ignore[union-attr]
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
- if self._camera is not None:
98
- log.info("Closing camera...")
99
- try:
100
- self._camera.stop_acquisition() # type: ignore[union-attr]
101
- self._camera.shutdown() # type: ignore[union-attr]
102
- except Exception:
103
- log.exception("Error closing camera.")
104
- self._camera = None
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 pop_frame() from a background thread (see _run_blocking) rather than polling it
182
- directly from the async loop with a sleep in between each attempt -- pop_frame() is
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
- camera = self._camera
196
- while camera is not None:
197
- frame = camera.pop_frame() # type: ignore[union-attr]
198
- # pop_frame() can return a non-None array that's empty along axis 0 instead of
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
- self._camera.set_exposure_time(exposure_time * 1e6) # type: ignore[union-attr]
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
- from typing import Any
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 = aravis.Camera(device)
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
- def _exposure_time_changed(self, value: float) -> None:
48
- self.camera.set_exposure_time(value * 1e6)
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._last_frame = await loop.run_in_executor(None, self.camera.pop_frame) # type: ignore
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 loop.run_in_executor(None, self.camera.pop_frame) # type: ignore
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.stop_acquisition()
71
- self.camera.shutdown()
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-aravis"
3
- version = "2.0.0.dev15"
3
+ version = "2.0.0.dev16"
4
4
  description = "pyobs module for Aravis cameras"
5
5
  authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
6
6
  requires-python = ">=3.11"
@@ -2320,7 +2320,7 @@ wheels = [
2320
2320
 
2321
2321
  [[package]]
2322
2322
  name = "pyobs-aravis"
2323
- version = "2.0.0.dev15"
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'" },