rocket-welder-sdk 1.1.32__tar.gz → 1.1.33__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 (64) hide show
  1. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/PKG-INFO +15 -2
  2. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/README.md +10 -1
  3. rocket_welder_sdk-1.1.33/VERSION +1 -0
  4. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/pyproject.toml +21 -0
  5. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/__init__.py +5 -6
  6. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/controllers.py +134 -101
  7. rocket_welder_sdk-1.1.33/rocket_welder_sdk/frame_metadata.py +138 -0
  8. rocket_welder_sdk-1.1.33/rocket_welder_sdk/high_level/__init__.py +66 -0
  9. rocket_welder_sdk-1.1.33/rocket_welder_sdk/high_level/connection_strings.py +330 -0
  10. rocket_welder_sdk-1.1.33/rocket_welder_sdk/high_level/data_context.py +163 -0
  11. rocket_welder_sdk-1.1.33/rocket_welder_sdk/high_level/schema.py +180 -0
  12. rocket_welder_sdk-1.1.33/rocket_welder_sdk/high_level/transport_protocol.py +166 -0
  13. rocket_welder_sdk-1.1.33/rocket_welder_sdk/keypoints_protocol.py +642 -0
  14. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/rocket_welder_client.py +17 -3
  15. rocket_welder_sdk-1.1.33/rocket_welder_sdk/segmentation_result.py +420 -0
  16. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/__init__.py +38 -0
  17. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/frame_sink.py +77 -0
  18. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/frame_source.py +74 -0
  19. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/nng_transport.py +197 -0
  20. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/stream_transport.py +193 -0
  21. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/tcp_transport.py +154 -0
  22. rocket_welder_sdk-1.1.33/rocket_welder_sdk/transport/unix_socket_transport.py +339 -0
  23. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk.egg-info/PKG-INFO +15 -2
  24. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk.egg-info/SOURCES.txt +23 -0
  25. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk.egg-info/requires.txt +5 -0
  26. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/setup.py +5 -4
  27. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_connection_string.py +116 -0
  28. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_controllers.py +72 -11
  29. rocket_welder_sdk-1.1.33/tests/test_frame_metadata.py +183 -0
  30. rocket_welder_sdk-1.1.33/tests/test_high_level_api.py +417 -0
  31. rocket_welder_sdk-1.1.33/tests/test_keypoints_cross_platform.py +216 -0
  32. rocket_welder_sdk-1.1.33/tests/test_keypoints_protocol.py +354 -0
  33. rocket_welder_sdk-1.1.33/tests/test_rocket_welder_client.py +254 -0
  34. rocket_welder_sdk-1.1.33/tests/test_segmentation_cross_platform.py +148 -0
  35. rocket_welder_sdk-1.1.33/tests/test_segmentation_result.py +430 -0
  36. rocket_welder_sdk-1.1.33/tests/test_transport_cross_platform.py +1207 -0
  37. rocket_welder_sdk-1.1.32/VERSION +0 -1
  38. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/MANIFEST.in +0 -0
  39. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/logo.png +0 -0
  40. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/bytes_size.py +0 -0
  41. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/connection_string.py +0 -0
  42. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/external_controls/__init__.py +0 -0
  43. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/external_controls/contracts.py +0 -0
  44. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/external_controls/contracts_old.py +0 -0
  45. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/gst_metadata.py +0 -0
  46. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/opencv_controller.py +0 -0
  47. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/periodic_timer.py +0 -0
  48. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/py.typed +0 -0
  49. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/__init__.py +0 -0
  50. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/controls.py +0 -0
  51. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/icons.py +0 -0
  52. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/ui_events_projection.py +0 -0
  53. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/ui_service.py +0 -0
  54. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk/ui/value_types.py +0 -0
  55. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk.egg-info/dependency_links.txt +0 -0
  56. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/rocket_welder_sdk.egg-info/top_level.txt +0 -0
  57. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/setup.cfg +0 -0
  58. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_bytes_size.py +0 -0
  59. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_external_controls_serialization.py +0 -0
  60. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_external_controls_serialization_v2.py +0 -0
  61. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_gst_metadata.py +0 -0
  62. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_icons.py +0 -0
  63. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_ui_controls.py +0 -0
  64. {rocket_welder_sdk-1.1.32 → rocket_welder_sdk-1.1.33}/tests/test_ui_service_happy_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rocket-welder-sdk
3
- Version: 1.1.32
3
+ Version: 1.1.33
4
4
  Summary: High-performance video streaming SDK for RocketWelder services using ZeroBuffer IPC
5
5
  Home-page: https://github.com/modelingevolution/rocket-welder-sdk
6
6
  Author: ModelingEvolution
@@ -31,6 +31,9 @@ Requires-Dist: opencv-python>=4.5.0
31
31
  Requires-Dist: zerobuffer-ipc>=1.1.17
32
32
  Requires-Dist: pydantic>=2.5.0
33
33
  Requires-Dist: py-micro-plumberd>=0.1.8
34
+ Requires-Dist: typing-extensions>=4.0.0
35
+ Provides-Extra: nng
36
+ Requires-Dist: pynng>=0.7.2; extra == "nng"
34
37
  Provides-Extra: dev
35
38
  Requires-Dist: pytest>=7.0; extra == "dev"
36
39
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -39,6 +42,7 @@ Requires-Dist: black>=22.0; extra == "dev"
39
42
  Requires-Dist: mypy>=1.0; extra == "dev"
40
43
  Requires-Dist: ruff>=0.1.0; extra == "dev"
41
44
  Requires-Dist: types-setuptools; extra == "dev"
45
+ Requires-Dist: pynng>=0.7.2; extra == "dev"
42
46
  Dynamic: author
43
47
  Dynamic: home-page
44
48
  Dynamic: requires-python
@@ -169,7 +173,7 @@ Start by testing your container locally before deploying to Neuron:
169
173
 
170
174
  ```bash
171
175
  # Build your container
172
- docker build -t my-ai-app:v1 -f examples/python/Dockerfile .
176
+ docker build -t my-ai-app:v1 -f python/examples/Dockerfile .
173
177
 
174
178
  # Test with a video file
175
179
  docker run --rm \
@@ -223,6 +227,15 @@ docker run --rm \
223
227
  my-ai-app:v1
224
228
  ```
225
229
 
230
+ You can also see preview in your terminal.
231
+ ```bash
232
+ docker run --rm \
233
+ -e CONNECTION_STRING="mjpeg+tcp://<neuron-ip>:<tcp-server-sink-port>?preview=true" \
234
+ -e DISPLAY=$DISPLAY \
235
+ -v /tmp/.X11-unix:/tmp/.X11-unix \
236
+ --network host my-ai-app:v1
237
+ ```
238
+
226
239
  This allows you to:
227
240
  - Test your AI processing with real camera feeds
228
241
  - Debug frame processing logic
@@ -124,7 +124,7 @@ Start by testing your container locally before deploying to Neuron:
124
124
 
125
125
  ```bash
126
126
  # Build your container
127
- docker build -t my-ai-app:v1 -f examples/python/Dockerfile .
127
+ docker build -t my-ai-app:v1 -f python/examples/Dockerfile .
128
128
 
129
129
  # Test with a video file
130
130
  docker run --rm \
@@ -178,6 +178,15 @@ docker run --rm \
178
178
  my-ai-app:v1
179
179
  ```
180
180
 
181
+ You can also see preview in your terminal.
182
+ ```bash
183
+ docker run --rm \
184
+ -e CONNECTION_STRING="mjpeg+tcp://<neuron-ip>:<tcp-server-sink-port>?preview=true" \
185
+ -e DISPLAY=$DISPLAY \
186
+ -v /tmp/.X11-unix:/tmp/.X11-unix \
187
+ --network host my-ai-app:v1
188
+ ```
189
+
181
190
  This allows you to:
182
191
  - Test your AI processing with real camera feeds
183
192
  - Debug frame processing logic
@@ -0,0 +1 @@
1
+ 1.1.33
@@ -38,9 +38,13 @@ dependencies = [
38
38
  "zerobuffer-ipc>=1.1.17",
39
39
  "pydantic>=2.5.0",
40
40
  "py-micro-plumberd>=0.1.8",
41
+ "typing-extensions>=4.0.0",
41
42
  ]
42
43
 
43
44
  [project.optional-dependencies]
45
+ nng = [
46
+ "pynng>=0.7.2",
47
+ ]
44
48
  dev = [
45
49
  "pytest>=7.0",
46
50
  "pytest-cov>=4.0",
@@ -49,6 +53,7 @@ dev = [
49
53
  "mypy>=1.0",
50
54
  "ruff>=0.1.0",
51
55
  "types-setuptools",
56
+ "pynng>=0.7.2",
52
57
  ]
53
58
 
54
59
  [project.urls]
@@ -76,6 +81,10 @@ namespace_packages = true
76
81
  show_error_codes = true
77
82
  show_column_numbers = true
78
83
  pretty = true
84
+ exclude = [
85
+ "examples/05-traktorek",
86
+ "examples/rocket-welder-client-python-yolo",
87
+ ]
79
88
 
80
89
  [[tool.mypy.overrides]]
81
90
  module = [
@@ -88,6 +97,8 @@ module = [
88
97
  "py_micro_plumberd.*",
89
98
  "esdbclient",
90
99
  "esdbclient.*",
100
+ "pynng",
101
+ "pynng.*",
91
102
  ]
92
103
  ignore_missing_imports = true
93
104
 
@@ -95,10 +106,20 @@ ignore_missing_imports = true
95
106
  line-length = 100
96
107
  target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
97
108
  include = '\.pyi?$'
109
+ exclude = '''
110
+ /(
111
+ examples/05-traktorek
112
+ | examples/rocket-welder-client-python-yolo
113
+ )/
114
+ '''
98
115
 
99
116
  [tool.ruff]
100
117
  line-length = 100
101
118
  target-version = "py38"
119
+ exclude = [
120
+ "examples/05-traktorek",
121
+ "examples/rocket-welder-client-python-yolo",
122
+ ]
102
123
 
103
124
  [tool.ruff.lint]
104
125
  select = [
@@ -10,6 +10,7 @@ import os
10
10
  from .bytes_size import BytesSize
11
11
  from .connection_string import ConnectionMode, ConnectionString, Protocol
12
12
  from .controllers import DuplexShmController, IController, OneWayShmController
13
+ from .frame_metadata import FRAME_METADATA_SIZE, FrameMetadata, GstVideoFormat
13
14
  from .gst_metadata import GstCaps, GstMetadata
14
15
  from .opencv_controller import OpenCvController
15
16
  from .periodic_timer import PeriodicTimer, PeriodicTimerSync
@@ -40,23 +41,21 @@ if _log_level:
40
41
  pass # Invalid log level, ignore
41
42
 
42
43
  __all__ = [
43
- # Core types
44
+ "FRAME_METADATA_SIZE",
44
45
  "BytesSize",
45
- "Client", # Backward compatibility
46
+ "Client",
46
47
  "ConnectionMode",
47
48
  "ConnectionString",
48
49
  "DuplexShmController",
49
- # GStreamer metadata
50
+ "FrameMetadata",
50
51
  "GstCaps",
51
52
  "GstMetadata",
52
- # Controllers
53
+ "GstVideoFormat",
53
54
  "IController",
54
55
  "OneWayShmController",
55
56
  "OpenCvController",
56
- # Timers
57
57
  "PeriodicTimer",
58
58
  "PeriodicTimerSync",
59
59
  "Protocol",
60
- # Main client
61
60
  "RocketWelderClient",
62
61
  ]
@@ -17,6 +17,7 @@ from zerobuffer.duplex import DuplexChannelFactory
17
17
  from zerobuffer.exceptions import WriterDeadException
18
18
 
19
19
  from .connection_string import ConnectionMode, ConnectionString, Protocol
20
+ from .frame_metadata import FRAME_METADATA_SIZE, FrameMetadata
20
21
  from .gst_metadata import GstCaps, GstMetadata
21
22
 
22
23
  if TYPE_CHECKING:
@@ -336,6 +337,9 @@ class OneWayShmController(IController):
336
337
  Create OpenCV Mat from frame data using GstCaps.
337
338
  Matches C# CreateMat behavior - creates Mat wrapping the data.
338
339
 
340
+ Frame data layout from GStreamer zerosink:
341
+ [FrameMetadata (16 bytes)][Pixel Data (W×H×C bytes)]
342
+
339
343
  Args:
340
344
  frame: ZeroBuffer frame
341
345
 
@@ -359,31 +363,40 @@ class OneWayShmController(IController):
359
363
  else:
360
364
  channels = 3 # Default to RGB
361
365
 
362
- # Get frame data directly as numpy array (zero-copy view)
363
- # Frame.data is already a memoryview/buffer that can be wrapped
364
- data = np.frombuffer(frame.data, dtype=np.uint8)
366
+ # Frame data has 16-byte FrameMetadata prefix that must be stripped
367
+ # Layout: [FrameMetadata (16 bytes)][Pixel Data]
368
+ if frame.size < FRAME_METADATA_SIZE:
369
+ logger.error(
370
+ "Frame too small for FrameMetadata: %d bytes (need at least %d)",
371
+ frame.size,
372
+ FRAME_METADATA_SIZE,
373
+ )
374
+ return None
375
+
376
+ # Get pixel data (skip 16-byte FrameMetadata prefix)
377
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
365
378
 
366
- # Check data size matches expected
379
+ # Check pixel data size matches expected
367
380
  expected_size = height * width * channels
368
- if len(data) != expected_size:
381
+ if len(pixel_data) != expected_size:
369
382
  logger.error(
370
- "Data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
383
+ "Pixel data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
371
384
  expected_size,
372
385
  width,
373
386
  height,
374
387
  channels,
375
- len(data),
388
+ len(pixel_data),
376
389
  )
377
390
  return None
378
391
 
379
392
  # Reshape to image dimensions - this is zero-copy, just changes the view
380
393
  # This matches C#: new Mat(Height, Width, Depth, Channels, ptr, Width * Channels)
381
394
  if channels == 3:
382
- mat = data.reshape((height, width, 3))
395
+ mat = pixel_data.reshape((height, width, 3))
383
396
  elif channels == 1:
384
- mat = data.reshape((height, width))
397
+ mat = pixel_data.reshape((height, width))
385
398
  elif channels == 4:
386
- mat = data.reshape((height, width, 4))
399
+ mat = pixel_data.reshape((height, width, 4))
387
400
  else:
388
401
  logger.error("Unsupported channel count: %d", channels)
389
402
  return None
@@ -393,41 +406,51 @@ class OneWayShmController(IController):
393
406
  # No caps available - try to infer from frame size
394
407
  logger.warning("No GstCaps available, attempting to infer from frame size")
395
408
 
396
- # Try common resolutions
397
- frame_size = len(frame.data)
409
+ # Frame data has 16-byte FrameMetadata prefix
410
+ if frame.size < FRAME_METADATA_SIZE:
411
+ logger.error(
412
+ "Frame too small for FrameMetadata: %d bytes (need at least %d)",
413
+ frame.size,
414
+ FRAME_METADATA_SIZE,
415
+ )
416
+ return None
417
+
418
+ # Calculate pixel data size (frame size minus 16-byte metadata prefix)
419
+ pixel_data_size = frame.size - FRAME_METADATA_SIZE
398
420
 
399
421
  # First, check if it's a perfect square (square frame)
400
422
  import math
401
423
 
402
- sqrt_size = math.sqrt(frame_size)
424
+ sqrt_size = math.sqrt(pixel_data_size)
403
425
  if sqrt_size == int(sqrt_size):
404
426
  # Perfect square - assume square grayscale image
405
427
  dimension = int(sqrt_size)
406
428
  logger.info(
407
- f"Frame size {frame_size} is a perfect square, assuming {dimension}x{dimension} grayscale"
429
+ f"Pixel data size {pixel_data_size} is a perfect square, "
430
+ f"assuming {dimension}x{dimension} grayscale"
408
431
  )
409
- data = np.frombuffer(frame.data, dtype=np.uint8)
410
- return data.reshape((dimension, dimension)) # type: ignore[no-any-return]
432
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
433
+ return pixel_data.reshape((dimension, dimension)) # type: ignore[no-any-return]
411
434
 
412
435
  # Also check for square RGB (size = width * height * 3)
413
- if frame_size % 3 == 0:
414
- pixels = frame_size // 3
436
+ if pixel_data_size % 3 == 0:
437
+ pixels = pixel_data_size // 3
415
438
  sqrt_pixels = math.sqrt(pixels)
416
439
  if sqrt_pixels == int(sqrt_pixels):
417
440
  dimension = int(sqrt_pixels)
418
- logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGB")
419
- data = np.frombuffer(frame.data, dtype=np.uint8)
420
- return data.reshape((dimension, dimension, 3)) # type: ignore[no-any-return]
441
+ logger.info(f"Pixel data size {pixel_data_size} suggests {dimension}x{dimension} RGB")
442
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
443
+ return pixel_data.reshape((dimension, dimension, 3)) # type: ignore[no-any-return]
421
444
 
422
445
  # Check for square RGBA (size = width * height * 4)
423
- if frame_size % 4 == 0:
424
- pixels = frame_size // 4
446
+ if pixel_data_size % 4 == 0:
447
+ pixels = pixel_data_size // 4
425
448
  sqrt_pixels = math.sqrt(pixels)
426
449
  if sqrt_pixels == int(sqrt_pixels):
427
450
  dimension = int(sqrt_pixels)
428
- logger.info(f"Frame size {frame_size} suggests {dimension}x{dimension} RGBA")
429
- data = np.frombuffer(frame.data, dtype=np.uint8)
430
- return data.reshape((dimension, dimension, 4)) # type: ignore[no-any-return]
451
+ logger.info(f"Pixel data size {pixel_data_size} suggests {dimension}x{dimension} RGBA")
452
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
453
+ return pixel_data.reshape((dimension, dimension, 4)) # type: ignore[no-any-return]
431
454
 
432
455
  common_resolutions = [
433
456
  (640, 480, 3), # VGA RGB
@@ -438,7 +461,7 @@ class OneWayShmController(IController):
438
461
  ]
439
462
 
440
463
  for width, height, channels in common_resolutions:
441
- if frame_size == width * height * channels:
464
+ if pixel_data_size == width * height * channels:
442
465
  logger.info(f"Inferred resolution: {width}x{height} with {channels} channels")
443
466
 
444
467
  # Create caps for future use
@@ -447,16 +470,16 @@ class OneWayShmController(IController):
447
470
  width=width, height=height, format=format_str
448
471
  )
449
472
 
450
- # Create Mat
451
- data = np.frombuffer(frame.data, dtype=np.uint8)
473
+ # Create Mat from pixel data (skip 16-byte FrameMetadata prefix)
474
+ pixel_data = np.frombuffer(frame.data[FRAME_METADATA_SIZE:], dtype=np.uint8)
452
475
  if channels == 3:
453
- return data.reshape((height, width, 3)) # type: ignore[no-any-return]
476
+ return pixel_data.reshape((height, width, 3)) # type: ignore[no-any-return]
454
477
  elif channels == 1:
455
- return data.reshape((height, width)) # type: ignore[no-any-return]
478
+ return pixel_data.reshape((height, width)) # type: ignore[no-any-return]
456
479
  elif channels == 4:
457
- return data.reshape((height, width, 4)) # type: ignore[no-any-return]
480
+ return pixel_data.reshape((height, width, 4)) # type: ignore[no-any-return]
458
481
 
459
- logger.error(f"Could not infer resolution for frame size {frame_size}")
482
+ logger.error(f"Could not infer resolution for pixel data size {pixel_data_size}")
460
483
  return None
461
484
 
462
485
  except Exception as e:
@@ -553,7 +576,7 @@ class DuplexShmController(IController):
553
576
  self._gst_caps: Optional[GstCaps] = None
554
577
  self._metadata: Optional[GstMetadata] = None
555
578
  self._is_running = False
556
- self._on_frame_callback: Optional[Callable[[Mat, Mat], None]] = None # type: ignore[valid-type]
579
+ self._on_frame_callback: Optional[Callable[[FrameMetadata, Mat, Mat], None]] = None # type: ignore[valid-type]
557
580
  self._frame_count = 0
558
581
 
559
582
  @property
@@ -567,14 +590,18 @@ class DuplexShmController(IController):
567
590
 
568
591
  def start(
569
592
  self,
570
- on_frame: Callable[[Mat, Mat], None], # type: ignore[override,valid-type]
593
+ on_frame: Callable[[FrameMetadata, Mat, Mat], None], # type: ignore[override,valid-type]
571
594
  cancellation_token: Optional[threading.Event] = None,
572
595
  ) -> None:
573
596
  """
574
- Start duplex frame processing.
597
+ Start duplex frame processing with FrameMetadata.
598
+
599
+ The callback receives FrameMetadata (frame number, timestamp, dimensions),
600
+ input Mat, and output Mat. The 24-byte metadata prefix is stripped from
601
+ the frame data before creating the input Mat.
575
602
 
576
603
  Args:
577
- on_frame: Callback that receives input frame and output frame to fill
604
+ on_frame: Callback that receives (FrameMetadata, input_mat, output_mat)
578
605
  cancellation_token: Optional cancellation token
579
606
  """
580
607
  if self._is_running:
@@ -590,7 +617,6 @@ class DuplexShmController(IController):
590
617
  )
591
618
 
592
619
  # Create duplex server using factory
593
- # Convert timeout from milliseconds to seconds for Python API
594
620
  if not self._connection.buffer_name:
595
621
  raise ValueError("Buffer name is required for shared memory connection")
596
622
  timeout_seconds = self._connection.timeout_ms / 1000.0
@@ -698,91 +724,98 @@ class DuplexShmController(IController):
698
724
 
699
725
  def _process_duplex_frame(self, request_frame: Frame, response_writer: Writer) -> None:
700
726
  """
701
- Process a frame in duplex mode.
727
+ Process a frame in duplex mode with FrameMetadata.
728
+
729
+ The frame data has a 24-byte FrameMetadata prefix that is stripped
730
+ before creating the input Mat.
702
731
 
703
732
  Args:
704
- request_frame: Input frame from the request
733
+ request_frame: Input frame from the request (with metadata prefix)
705
734
  response_writer: Writer for the response frame
706
735
  """
707
- logger.debug(
708
- "_process_duplex_frame called, frame_count=%d, has_gst_caps=%s",
709
- self._frame_count,
710
- self._gst_caps is not None,
711
- )
712
736
  try:
713
737
  if not self._on_frame_callback:
714
738
  logger.warning("No frame callback set")
715
739
  return
716
740
 
741
+ # Check frame size is sufficient for metadata
742
+ if request_frame.size < FRAME_METADATA_SIZE:
743
+ logger.warning("Frame too small for FrameMetadata: %d bytes", request_frame.size)
744
+ return
745
+
717
746
  self._frame_count += 1
718
747
 
719
- # Try to read metadata if we don't have it yet
720
- if (
721
- self._metadata is None
722
- and self._duplex_server
723
- and self._duplex_server.request_reader
724
- ):
725
- try:
726
- metadata_bytes = self._duplex_server.request_reader.get_metadata()
727
- if metadata_bytes:
728
- # Use helper method to parse metadata
729
- metadata = self._parse_metadata_json(metadata_bytes)
730
- if metadata:
731
- self._metadata = metadata
732
- self._gst_caps = metadata.caps
733
- logger.info(
734
- "Successfully read metadata from buffer '%s': %s",
735
- self._connection.buffer_name,
736
- self._gst_caps,
737
- )
738
- else:
739
- logger.debug("Failed to parse metadata in frame processing")
740
- except Exception as e:
741
- logger.debug("Failed to read metadata in frame processing: %s", e)
748
+ # Parse FrameMetadata from the beginning of the frame
749
+ frame_metadata = FrameMetadata.from_bytes(request_frame.data)
742
750
 
743
- # Convert input frame to Mat
744
- input_mat = self._frame_to_mat(request_frame)
745
- if input_mat is None:
746
- logger.error("Failed to convert frame to Mat, gst_caps=%s", self._gst_caps)
751
+ # Calculate pixel data offset and size
752
+ pixel_data_offset = FRAME_METADATA_SIZE
753
+ pixel_data_size = request_frame.size - FRAME_METADATA_SIZE
754
+
755
+ # GstCaps must be available for width/height/format
756
+ # (FrameMetadata no longer contains these - they're stream-level, not per-frame)
757
+ if not self._gst_caps:
758
+ logger.warning(
759
+ "GstCaps not available, skipping frame %d", frame_metadata.frame_number
760
+ )
747
761
  return
748
762
 
749
- # Get buffer for output frame - use context manager for RAII
750
- with response_writer.get_frame_buffer(request_frame.size) as output_buffer:
751
- # Create output Mat from buffer (zero-copy)
752
- if self._gst_caps:
753
- height = self._gst_caps.height or 480
754
- width = self._gst_caps.width or 640
763
+ width = self._gst_caps.width
764
+ height = self._gst_caps.height
765
+ format_str = self._gst_caps.format
766
+
767
+ # Determine channels from format
768
+ if format_str in ["RGB", "BGR"]:
769
+ channels = 3
770
+ elif format_str in ["RGBA", "BGRA", "ARGB", "ABGR"]:
771
+ channels = 4
772
+ elif format_str in ["GRAY8", "GRAY16_LE", "GRAY16_BE"]:
773
+ channels = 1
774
+ else:
775
+ channels = 3 # Default to RGB
776
+
777
+ # Create input Mat from pixel data (after metadata prefix)
778
+ pixel_data = np.frombuffer(request_frame.data[pixel_data_offset:], dtype=np.uint8)
779
+
780
+ expected_size = height * width * channels
781
+ if len(pixel_data) != expected_size:
782
+ logger.error(
783
+ "Pixel data size mismatch. Expected %d bytes for %dx%d with %d channels, got %d",
784
+ expected_size,
785
+ width,
786
+ height,
787
+ channels,
788
+ len(pixel_data),
789
+ )
790
+ return
755
791
 
756
- if self._gst_caps.format == "RGB" or self._gst_caps.format == "BGR":
757
- output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
758
- (height, width, 3)
759
- )
760
- elif self._gst_caps.format == "GRAY8":
761
- output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
762
- (height, width)
763
- )
764
- else:
765
- # Default to same shape as input
766
- output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
767
- input_mat.shape
768
- )
792
+ # Reshape to image dimensions
793
+ if channels == 1:
794
+ input_mat = pixel_data.reshape((height, width))
795
+ else:
796
+ input_mat = pixel_data.reshape((height, width, channels))
797
+
798
+ # Response doesn't need metadata prefix - just pixel data
799
+ with response_writer.get_frame_buffer(pixel_data_size) as output_buffer:
800
+ # Create output Mat from buffer (zero-copy)
801
+ output_data = np.frombuffer(output_buffer, dtype=np.uint8)
802
+ if channels == 1:
803
+ output_mat = output_data.reshape((height, width))
769
804
  else:
770
- # Use same shape as input
771
- output_mat = np.frombuffer(output_buffer, dtype=np.uint8).reshape(
772
- input_mat.shape
773
- )
805
+ output_mat = output_data.reshape((height, width, channels))
774
806
 
775
- # Call user's processing function
776
- self._on_frame_callback(input_mat, output_mat)
807
+ # Call user's processing function with metadata
808
+ self._on_frame_callback(frame_metadata, input_mat, output_mat)
777
809
 
778
810
  # Commit the response frame after buffer is released
779
811
  response_writer.commit_frame()
780
812
 
781
813
  logger.debug(
782
- "Processed duplex frame %d (%dx%d)",
783
- self._frame_count,
784
- input_mat.shape[1],
785
- input_mat.shape[0],
814
+ "Processed duplex frame %d (%dx%d %s)",
815
+ frame_metadata.frame_number,
816
+ width,
817
+ height,
818
+ format_str,
786
819
  )
787
820
 
788
821
  except Exception as e: