racs-python 0.0.2__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: racs-python
3
- Version: 0.0.2
3
+ Version: 0.3.0
4
4
  Summary: Client library to access racs api server
5
5
  Author-email: Jonas Ahlf <javerik@javerik.space>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -349,55 +349,127 @@ Keywords: racs,aravis,camera,client,api,genicam,gev,gigevision
349
349
  Classifier: Programming Language :: Python :: 3
350
350
  Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
351
351
  Classifier: Operating System :: OS Independent
352
- Requires-Python: >=3.7
352
+ Requires-Python: >=3.9
353
353
  Description-Content-Type: text/markdown
354
354
  License-File: LICENSE
355
355
  Requires-Dist: requests>=2.31
356
356
  Requires-Dist: pydantic>=2.0
357
+ Requires-Dist: websocket-client>=1.6
358
+ Provides-Extra: numpy
359
+ Requires-Dist: numpy; extra == "numpy"
360
+ Provides-Extra: test
361
+ Requires-Dist: pytest; extra == "test"
362
+ Requires-Dist: numpy; extra == "test"
357
363
  Dynamic: license-file
358
364
 
359
365
  # RACS python client library
360
366
 
361
- Simple client which uses `requests` to interact with the [racs-api server](https://codeberg.org/javerik/RACS-RustAravisCamServe).
367
+ Client for the [racs-api server](https://codeberg.org/javerik/RACS-RustAravisCamServe): device
368
+ discovery and connection, typed GenICam feature access, single frames, the live stream
369
+ (WebSocket) and burst jobs.
362
370
 
363
371
  ## Install
364
372
 
365
373
  ```shell
366
- pip install racs-python
374
+ pip install racs-python # numpy is optional:
375
+ pip install "racs-python[numpy]" # adds Frame.to_numpy()
367
376
  ```
368
377
 
369
378
  ## Usage
370
379
 
371
380
  > **Make sure an instance of racs-api server is running**
372
381
 
382
+ A `Camera` holds the device id, so nothing else needs to pass it around:
383
+
373
384
  ```python
374
385
  from racs_python import RacsClient
375
386
 
376
- client = RacsClient("http://localhost:8742/api/v1")
387
+ client = RacsClient("http://localhost:8742") # "/api/v1" is added to a bare server URL
377
388
 
378
- # discover devices
379
- devices = client.get_devices()
389
+ devices = client.get_devices() # network discovery
380
390
  print(f"Found {len(devices.available)} available devices.")
381
391
 
382
- if devices.available:
383
- target_id = devices.available[0].id
384
-
385
- # 2. Connection
386
- client.connect_device(target_id)
387
- print(f"Connected to device {target_id}")
392
+ cam = client.connect(devices.available[0].id) # or client.connect(5, ip="192.168.0.10")
388
393
 
389
- # 3. Read/Write Settings
390
- width = client.get_device_gc_node_value(target_id, "Width")
391
- print(f"Current Width: {width}")
392
-
393
- client.set_device_gc_node_value(target_id, "Width", "1920")
394
+ # Features are read and written as Python values
395
+ print(cam["Width"]) # 1280 (int)
396
+ cam["ExposureTime"] = 5000.0
397
+ cam["ReverseX"] = True
398
+ width, height = cam.get_many("Width", "Height").values()
399
+ cam.execute("TriggerSoftware")
400
+
401
+ # Single frames
402
+ with open("frame.jpg", "wb") as f:
403
+ f.write(cam.grab_jpeg(quality=90))
404
+ frame = cam.grab_raw() # Mono8, with width/height
405
+ pixels = frame.to_numpy() # (height, width) uint8
406
+
407
+ cam.disconnect()
408
+ ```
409
+
410
+ Already connected devices: `client.cameras()`, or `client.camera(id)` for a handle without a request.
411
+
412
+ ### Live stream
413
+
414
+ The stream holds the camera until it is closed. Features can still be read and written while it
415
+ runs; a change of size arrives as a new `stream.info` and applies to the frames after it.
416
+
417
+ ```python
418
+ from itertools import islice
419
+
420
+ with cam.live(format="raw", pixel_depth="mono16", fps=10) as stream:
421
+ for frame in islice(stream, 100):
422
+ pixels = frame.to_numpy() # uint16 at bit_depth > 8
423
+ print(frame.frame_number, frame.dropped, frame.bit_depth, pixels.mean())
424
+ ```
425
+
426
+ `format="jpeg"` (the default) delivers JPEG files in `frame.data`. A stream that the server ends
427
+ because of an error raises `LiveStreamError`; `cam.stream_error()` reports the last one.
394
428
 
395
- # 4. Grab an image
396
- jpg_data = client.grab_jpg_frame(target_id)
397
- with open("frame.jpg", "wb") as f:
398
- f.write(jpg_data)
429
+ ### Packet size
399
430
 
400
- # 5. Cleanup
401
- client.disconnect_device(target_id)
431
+ Every grab, stream and burst uses the camera's stream packet size (`GevSCPSPacketSize`, bytes per
432
+ packet including IP and UDP headers) as it is. Jumbo frames such as 7960 need a link MTU of 9000.
402
433
 
434
+ ```python
435
+ info = cam.packet_size() # packet_size, min/max/inc, link_mtu, interface
436
+ cam["GevSCPSPacketSize"] = 7960 # a multiple of info.inc, not above the link MTU
437
+ auto = cam.auto_packet_size() # the largest size whose test packets arrive
438
+ ```
439
+
440
+ Both work during a live stream, which pauses briefly and carries on with the new size
441
+ (`stream.info.packet_size`).
442
+
443
+ ### Burst jobs
444
+
445
+ A burst grabs up to 500 frames on the server in the background. Leaving the `with` block deletes
446
+ the job and its frames.
447
+
448
+ ```python
449
+ with cam.burst(50, format="raw", pixel_depth="mono16") as job:
450
+ for frame in job.frames(): # yields each frame as soon as it is captured
451
+ ...
452
+ info = job.wait() # final status
403
453
  ```
454
+
455
+ `job.image(i)` returns the encoded bytes of one frame, `job.images()` lists what has been
456
+ captured, and `cam.job(job_id)` re-attaches to a job by id.
457
+
458
+ ### Errors
459
+
460
+ Every error is a `RacsError`. HTTP errors are `RacsHttpError` (a `requests.HTTPError`) with
461
+ `status_code` and `body`; the specific ones are `CameraBusyError` (the camera is held by a stream,
462
+ burst or grab), `NotFoundError` and `ImageFailedError`. Feature reads raise `FeatureError`, a
463
+ failed burst `BurstJobFailedError`.
464
+
465
+ ### Low-level client
466
+
467
+ `RacsClient` maps one method onto each REST route and takes the device id every time, e.g.
468
+ `client.get_device_gc_node_value(0, "Width")` or `client.start_grab_job(0, 20, "raw")`. Values
469
+ are strings there, exactly as the server sends them.
470
+
471
+ ## Tests
472
+
473
+ `client_libs/run-tests.sh python` builds the repository's fake-camera server
474
+ (`cargo run --example fake_server`), starts it and runs the tests against it. `pytest` on its own
475
+ runs only the tests that need no server.
@@ -0,0 +1,111 @@
1
+ # RACS python client library
2
+
3
+ Client for the [racs-api server](https://codeberg.org/javerik/RACS-RustAravisCamServe): device
4
+ discovery and connection, typed GenICam feature access, single frames, the live stream
5
+ (WebSocket) and burst jobs.
6
+
7
+ ## Install
8
+
9
+ ```shell
10
+ pip install racs-python # numpy is optional:
11
+ pip install "racs-python[numpy]" # adds Frame.to_numpy()
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ > **Make sure an instance of racs-api server is running**
17
+
18
+ A `Camera` holds the device id, so nothing else needs to pass it around:
19
+
20
+ ```python
21
+ from racs_python import RacsClient
22
+
23
+ client = RacsClient("http://localhost:8742") # "/api/v1" is added to a bare server URL
24
+
25
+ devices = client.get_devices() # network discovery
26
+ print(f"Found {len(devices.available)} available devices.")
27
+
28
+ cam = client.connect(devices.available[0].id) # or client.connect(5, ip="192.168.0.10")
29
+
30
+ # Features are read and written as Python values
31
+ print(cam["Width"]) # 1280 (int)
32
+ cam["ExposureTime"] = 5000.0
33
+ cam["ReverseX"] = True
34
+ width, height = cam.get_many("Width", "Height").values()
35
+ cam.execute("TriggerSoftware")
36
+
37
+ # Single frames
38
+ with open("frame.jpg", "wb") as f:
39
+ f.write(cam.grab_jpeg(quality=90))
40
+ frame = cam.grab_raw() # Mono8, with width/height
41
+ pixels = frame.to_numpy() # (height, width) uint8
42
+
43
+ cam.disconnect()
44
+ ```
45
+
46
+ Already connected devices: `client.cameras()`, or `client.camera(id)` for a handle without a request.
47
+
48
+ ### Live stream
49
+
50
+ The stream holds the camera until it is closed. Features can still be read and written while it
51
+ runs; a change of size arrives as a new `stream.info` and applies to the frames after it.
52
+
53
+ ```python
54
+ from itertools import islice
55
+
56
+ with cam.live(format="raw", pixel_depth="mono16", fps=10) as stream:
57
+ for frame in islice(stream, 100):
58
+ pixels = frame.to_numpy() # uint16 at bit_depth > 8
59
+ print(frame.frame_number, frame.dropped, frame.bit_depth, pixels.mean())
60
+ ```
61
+
62
+ `format="jpeg"` (the default) delivers JPEG files in `frame.data`. A stream that the server ends
63
+ because of an error raises `LiveStreamError`; `cam.stream_error()` reports the last one.
64
+
65
+ ### Packet size
66
+
67
+ Every grab, stream and burst uses the camera's stream packet size (`GevSCPSPacketSize`, bytes per
68
+ packet including IP and UDP headers) as it is. Jumbo frames such as 7960 need a link MTU of 9000.
69
+
70
+ ```python
71
+ info = cam.packet_size() # packet_size, min/max/inc, link_mtu, interface
72
+ cam["GevSCPSPacketSize"] = 7960 # a multiple of info.inc, not above the link MTU
73
+ auto = cam.auto_packet_size() # the largest size whose test packets arrive
74
+ ```
75
+
76
+ Both work during a live stream, which pauses briefly and carries on with the new size
77
+ (`stream.info.packet_size`).
78
+
79
+ ### Burst jobs
80
+
81
+ A burst grabs up to 500 frames on the server in the background. Leaving the `with` block deletes
82
+ the job and its frames.
83
+
84
+ ```python
85
+ with cam.burst(50, format="raw", pixel_depth="mono16") as job:
86
+ for frame in job.frames(): # yields each frame as soon as it is captured
87
+ ...
88
+ info = job.wait() # final status
89
+ ```
90
+
91
+ `job.image(i)` returns the encoded bytes of one frame, `job.images()` lists what has been
92
+ captured, and `cam.job(job_id)` re-attaches to a job by id.
93
+
94
+ ### Errors
95
+
96
+ Every error is a `RacsError`. HTTP errors are `RacsHttpError` (a `requests.HTTPError`) with
97
+ `status_code` and `body`; the specific ones are `CameraBusyError` (the camera is held by a stream,
98
+ burst or grab), `NotFoundError` and `ImageFailedError`. Feature reads raise `FeatureError`, a
99
+ failed burst `BurstJobFailedError`.
100
+
101
+ ### Low-level client
102
+
103
+ `RacsClient` maps one method onto each REST route and takes the device id every time, e.g.
104
+ `client.get_device_gc_node_value(0, "Width")` or `client.start_grab_job(0, 20, "raw")`. Values
105
+ are strings there, exactly as the server sends them.
106
+
107
+ ## Tests
108
+
109
+ `client_libs/run-tests.sh python` builds the repository's fake-camera server
110
+ (`cargo run --example fake_server`), starts it and runs the tests against it. `pytest` on its own
111
+ runs only the tests that need no server.
@@ -4,19 +4,20 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "racs-python"
7
- version = "0.0.2"
7
+ version = "0.3.0"
8
8
  authors = [
9
9
  { name="Jonas Ahlf", email="javerik@javerik.space" },
10
10
  ]
11
11
  description = "Client library to access racs api server"
12
12
  readme = "README.md"
13
- requires-python = ">=3.7"
13
+ requires-python = ">=3.9"
14
14
  license = { file = "LICENSE" }
15
15
 
16
16
  # Added specific version constraints for stability
17
17
  dependencies = [
18
18
  "requests>=2.31",
19
- "pydantic>=2.0" # Required for the code provided (V2)
19
+ "pydantic>=2.0", # Required for the code provided (V2)
20
+ "websocket-client>=1.6" # Live stream
20
21
  ]
21
22
 
22
23
  classifiers = [
@@ -29,4 +30,11 @@ keywords = ["racs", "aravis", "camera", "client", "api", "genicam", "gev", "gige
29
30
 
30
31
  [project.urls]
31
32
  "Homepage" = "https://codeberg.org/javerik/RACS-RustAravisCamServe"
32
- "Bug Tracker" = "https://codeberg.org/javerik/RACS-RustAravisCamServe/issues"
33
+ "Bug Tracker" = "https://codeberg.org/javerik/RACS-RustAravisCamServe/issues"
34
+
35
+ [project.optional-dependencies]
36
+ numpy = ["numpy"] # Frame.to_numpy()
37
+ test = ["pytest", "numpy"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,41 @@
1
+ from .burst import BurstJob
2
+ from .camera import Camera
3
+ from .client import RacsClient
4
+ from .errors import (
5
+ BurstJobFailedError,
6
+ CameraBusyError,
7
+ FeatureError,
8
+ ImageFailedError,
9
+ LiveStreamError,
10
+ NotFoundError,
11
+ RacsError,
12
+ RacsHttpError,
13
+ )
14
+ from .frame import Frame
15
+ from .live import LiveStream
16
+ from .models import (
17
+ AutoPacketSize,
18
+ BurstFormat,
19
+ BurstJobCreated,
20
+ BurstJobInfo,
21
+ DeviceConnections,
22
+ DeviceDiscoverInfo,
23
+ FeatureState,
24
+ GenICamAccessMode,
25
+ GenICamEnumInfo,
26
+ GenICamNodeCategory,
27
+ GenICamNodeInfo,
28
+ GenICamNodeType,
29
+ GenICamVisibility,
30
+ ImageEntryFailed,
31
+ ImageEntryOk,
32
+ JobStatus,
33
+ LiveFormat,
34
+ OnFailure,
35
+ PacketSizeInfo,
36
+ PixelDepth,
37
+ StreamErrorInfo,
38
+ StreamInfo,
39
+ )
40
+
41
+ __version__ = "0.3.0"
@@ -0,0 +1,119 @@
1
+ import time
2
+ from typing import TYPE_CHECKING, Iterator, List, Optional, Union
3
+ from uuid import UUID
4
+
5
+ from .errors import BurstJobFailedError, NotFoundError, RacsError
6
+ from .frame import Frame
7
+ from .models import BurstJobInfo, ImageEntry, ImageEntryOk, JobStatus
8
+
9
+ if TYPE_CHECKING:
10
+ from .camera import Camera
11
+
12
+
13
+ class BurstJob:
14
+ """
15
+ A burst job on the server. The frames stay there until the job is deleted, or expire 10 min
16
+ after it finishes.
17
+
18
+ Used as a context manager, the job is deleted on exit::
19
+
20
+ with cam.burst(20, format="raw") as job:
21
+ for frame in job.frames():
22
+ ...
23
+ """
24
+
25
+ def __init__(self, camera: "Camera", job_id: Union[UUID, str]):
26
+ self.camera = camera
27
+ self.id = job_id if isinstance(job_id, UUID) else UUID(str(job_id))
28
+ # The first status that carried the frame geometry; it is the same for every frame.
29
+ self._geometry: Optional[BurstJobInfo] = None
30
+
31
+ @property
32
+ def _client(self):
33
+ return self.camera.client
34
+
35
+ def info(self) -> BurstJobInfo:
36
+ """The job's status and progress."""
37
+ info = self._client.get_grab_job(self.camera.id, self.id)
38
+ if info.width is not None:
39
+ self._geometry = info
40
+ return info
41
+
42
+ def images(self) -> List[ImageEntry]:
43
+ """Every frame attempted so far, in capture order: ImageEntryOk or ImageEntryFailed."""
44
+ return self._client.list_grab_job_images(self.camera.id, self.id)
45
+
46
+ def image(self, index: int) -> bytes:
47
+ """The encoded bytes of frame ``index``."""
48
+ return self._client.get_grab_job_image(self.camera.id, self.id, index)
49
+
50
+ def frame(self, index: int) -> Frame:
51
+ """Frame ``index`` with its geometry, ready for :meth:`Frame.to_numpy` (raw jobs)."""
52
+ data = self.image(index)
53
+ geometry = self._geometry or self.info()
54
+ if geometry.width is None or geometry.height is None:
55
+ raise RacsError(f"burst job {self.id} reports no frame geometry")
56
+ return Frame(
57
+ data=data,
58
+ width=geometry.width,
59
+ height=geometry.height,
60
+ format=geometry.format.value,
61
+ pixel_format=geometry.pixel_format or "Mono8",
62
+ bit_depth=geometry.bit_depth or 8,
63
+ )
64
+
65
+ def wait(self, timeout: Optional[float] = None, poll_interval: float = 0.1) -> BurstJobInfo:
66
+ """
67
+ Block until the job is completed or cancelled and return its final status. Raises
68
+ BurstJobFailedError if it failed and TimeoutError after ``timeout`` seconds.
69
+ """
70
+ deadline = None if timeout is None else time.monotonic() + timeout
71
+ while True:
72
+ info = self.info()
73
+ if info.status.is_terminal:
74
+ if info.status == JobStatus.FAILED:
75
+ raise BurstJobFailedError(info)
76
+ return info
77
+ if deadline is not None and time.monotonic() >= deadline:
78
+ raise TimeoutError(f"burst job {self.id} still {info.status.value} after {timeout} s")
79
+ time.sleep(poll_interval)
80
+
81
+ def frames(self, poll_interval: float = 0.1) -> Iterator[Frame]:
82
+ """
83
+ Yield every captured frame in order, as soon as it is on the server; failed frames (only
84
+ with ``on_failure="skip"``) are left out. Ends when the job is done, and raises
85
+ BurstJobFailedError after the last frame if the job failed.
86
+ """
87
+ next_index = 0
88
+ while True:
89
+ # Status first: once it is terminal, the listing that follows is complete.
90
+ info = self.info()
91
+ for entry in self.images():
92
+ if entry.index < next_index:
93
+ continue
94
+ next_index = entry.index + 1
95
+ if isinstance(entry, ImageEntryOk):
96
+ yield self.frame(entry.index)
97
+ if info.status.is_terminal:
98
+ if info.status == JobStatus.FAILED:
99
+ raise BurstJobFailedError(info)
100
+ return
101
+ time.sleep(poll_interval)
102
+
103
+ def delete(self) -> None:
104
+ """Cancel the job if it still runs, and drop it and its frames from the server."""
105
+ self._client.delete_grab_job(self.camera.id, self.id)
106
+
107
+ cancel = delete
108
+
109
+ def __enter__(self) -> "BurstJob":
110
+ return self
111
+
112
+ def __exit__(self, *exc) -> None:
113
+ try:
114
+ self.delete()
115
+ except NotFoundError:
116
+ pass
117
+
118
+ def __repr__(self) -> str:
119
+ return f"BurstJob(device_id={self.camera.id}, id='{self.id}')"