plexus-python 0.4.7__py3-none-any.whl → 0.4.8__py3-none-any.whl

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.
plexus/__init__.py CHANGED
@@ -7,8 +7,8 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
7
7
  px.send("temperature", 72.5)
8
8
  """
9
9
 
10
- from plexus.client import Plexus
10
+ from plexus.client import Plexus, read_mjpeg_frames
11
11
  from plexus.ws import WebSocketTransport
12
12
 
13
- __version__ = "0.4.7"
14
- __all__ = ["Plexus", "WebSocketTransport"]
13
+ __version__ = "0.4.8"
14
+ __all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
plexus/client.py CHANGED
@@ -38,10 +38,13 @@ import gzip
38
38
  import json
39
39
  import logging
40
40
  import os
41
+ import shutil
42
+ import subprocess
41
43
  import sys
44
+ import threading
42
45
  import time
43
46
  from contextlib import contextmanager
44
- from typing import Any, Dict, List, Optional, Tuple, Union
47
+ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
45
48
 
46
49
  import requests
47
50
 
@@ -75,6 +78,36 @@ def _say(line: str) -> None:
75
78
  # Flexible value type - supports any JSON-serializable value
76
79
  FlexValue = Union[int, float, str, bool, Dict[str, Any], List[Any]]
77
80
 
81
+ _JPEG_SOI = b"\xff\xd8"
82
+ _JPEG_EOI = b"\xff\xd9"
83
+ _FRAME_JPEG_MAX = 750_000 # gateway is 1MB; base64 × 1.33 + envelope ≈ 998KB at this size
84
+
85
+
86
+ def read_mjpeg_frames(pipe, chunk: int = 65536) -> Generator[bytes, None, None]:
87
+ """Read a raw MJPEG byte stream (e.g. FFmpeg stdout) and yield complete JPEG frames.
88
+
89
+ Scans for SOI (\xff\xd8) / EOI (\xff\xd9) markers to delimit frames.
90
+ Useful when building custom FFmpeg pipelines and handing off bytes to
91
+ send_video_frame().
92
+ """
93
+ buf = b""
94
+ while True:
95
+ data = pipe.read(chunk)
96
+ if not data:
97
+ break
98
+ buf += data
99
+ while True:
100
+ start = buf.find(_JPEG_SOI)
101
+ if start == -1:
102
+ buf = b""
103
+ break
104
+ end = buf.find(_JPEG_EOI, start + 2)
105
+ if end == -1:
106
+ buf = buf[start:] # keep partial frame
107
+ break
108
+ yield buf[start:end + 2]
109
+ buf = buf[end + 2:]
110
+
78
111
 
79
112
  class PlexusError(Exception):
80
113
  """Base exception for Plexus errors."""
@@ -136,6 +169,8 @@ class Plexus:
136
169
  self._session: Optional[requests.Session] = None
137
170
  self._store_frames: bool = False
138
171
  self._cv2 = None
172
+ self._pil_image = None # lazy PIL.Image import
173
+ self._fit_warned: bool = False
139
174
 
140
175
  if transport not in ("ws", "http"):
141
176
  raise ValueError(f"transport must be 'ws' or 'http', got {transport!r}")
@@ -344,6 +379,119 @@ class Plexus:
344
379
  except Exception as e: # pragma: no cover - persistence failure is non-fatal
345
380
  logger.debug("failed to persist assigned source_id: %s", e)
346
381
 
382
+ def _encode_frame(self, frame, quality: int) -> Tuple[bytes, int, int]:
383
+ """Normalize any supported frame type to (jpeg_bytes, width, height).
384
+
385
+ Accepted inputs:
386
+ - bytes/bytearray: raw JPEG passthrough (magic \\xff\\xd8), or any
387
+ Pillow-readable format (PNG, BMP, WebP) which is decoded and re-encoded
388
+ - numpy ndarray: encoded via OpenCV (cv2 must be installed)
389
+ """
390
+ import io
391
+
392
+ # --- bytes input ---
393
+ if isinstance(frame, (bytes, bytearray)):
394
+ if frame[:2] == b"\xff\xd8":
395
+ # Already JPEG — passthrough, extract dimensions via Pillow if available
396
+ try:
397
+ pil = self._get_pil()
398
+ img = pil.open(io.BytesIO(frame))
399
+ return bytes(frame), img.width, img.height
400
+ except Exception:
401
+ # Pillow unavailable or unreadable — send as-is, dimensions unknown
402
+ return bytes(frame), 0, 0
403
+ # Non-JPEG bytes (PNG, BMP, WebP, …) — Pillow decode then re-encode as JPEG
404
+ pil = self._get_pil(required=True)
405
+ img = pil.open(io.BytesIO(frame))
406
+ return self._pil_to_jpeg(img, quality)
407
+
408
+ # --- numpy array (OpenCV path) ---
409
+ if hasattr(frame, "shape"):
410
+ cv2 = self._get_cv2(required=True)
411
+ height, width = frame.shape[:2]
412
+ ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
413
+ if not ok:
414
+ raise PlexusError("cv2.imencode failed to encode frame")
415
+ return buf.tobytes(), width, height
416
+
417
+ raise ValueError(
418
+ f"Unsupported frame type: {type(frame).__name__}. "
419
+ "Expected bytes/bytearray (JPEG or Pillow-readable) or numpy ndarray."
420
+ )
421
+
422
+ def _get_cv2(self, required: bool = False):
423
+ if self._cv2 is None:
424
+ try:
425
+ import cv2 as _cv2
426
+ self._cv2 = _cv2
427
+ except ImportError as e:
428
+ if required:
429
+ raise ImportError(
430
+ "This frame type requires opencv-python. "
431
+ "Install with: pip install opencv-python"
432
+ ) from e
433
+ return self._cv2
434
+
435
+ def _get_pil(self, required: bool = False):
436
+ if self._pil_image is None:
437
+ try:
438
+ import PIL.Image as _PILImage
439
+ self._pil_image = _PILImage
440
+ except ImportError as e:
441
+ if required:
442
+ raise ImportError(
443
+ "This frame type requires Pillow. "
444
+ "Install with: pip install plexus-python[video]"
445
+ ) from e
446
+ return self._pil_image
447
+
448
+ def _pil_to_jpeg(self, img, quality: int) -> Tuple[bytes, int, int]:
449
+ import io
450
+ if img.mode not in ("RGB", "L"):
451
+ img = img.convert("RGB")
452
+ buf = io.BytesIO()
453
+ img.save(buf, format="JPEG", quality=quality)
454
+ return buf.getvalue(), img.width, img.height
455
+
456
+ def _fit_to_wire(self, jpeg_bytes: bytes, requested_quality: int) -> bytes:
457
+ """Re-encode JPEG at lower quality if it would exceed the gateway 1MB limit.
458
+
459
+ Warns once per Plexus instance so the user sees the issue at startup
460
+ without being flooded during a live stream.
461
+ """
462
+ import io
463
+ if len(jpeg_bytes) <= _FRAME_JPEG_MAX:
464
+ return jpeg_bytes
465
+ target_quality = max(10, int(requested_quality * _FRAME_JPEG_MAX / len(jpeg_bytes)))
466
+ pil = self._get_pil()
467
+ if pil is None:
468
+ if not self._fit_warned:
469
+ self._fit_warned = True
470
+ wire_kb = len(jpeg_bytes) * 4 // 3 // 1024
471
+ _say(
472
+ f"frame too large (~{wire_kb}KB on wire) and Pillow is not installed — "
473
+ "install plexus-python[video] to enable automatic downsampling"
474
+ )
475
+ return jpeg_bytes
476
+ try:
477
+ img = pil.open(io.BytesIO(jpeg_bytes))
478
+ buf = io.BytesIO()
479
+ if img.mode not in ("RGB", "L"):
480
+ img = img.convert("RGB")
481
+ img.save(buf, format="JPEG", quality=target_quality)
482
+ result = buf.getvalue()
483
+ if not self._fit_warned:
484
+ self._fit_warned = True
485
+ wire_kb = len(jpeg_bytes) * 4 // 3 // 1024
486
+ _say(
487
+ f"frame too large (quality={requested_quality}, ~{wire_kb}KB on wire), "
488
+ f"re-encoded at quality={target_quality} — lower quality or resolution to silence"
489
+ )
490
+ return result
491
+ except Exception as e:
492
+ logger.debug("_fit_to_wire re-encode failed: %s", e)
493
+ return jpeg_bytes
494
+
347
495
  def send_video_frame(
348
496
  self,
349
497
  frame,
@@ -351,43 +499,32 @@ class Plexus:
351
499
  quality: int = 85,
352
500
  timestamp: Optional[float] = None,
353
501
  ) -> bool:
354
- """
355
- Send a single video frame to Plexus (WebSocket transport only).
502
+ """Send a single video frame to Plexus (WebSocket transport only).
356
503
 
357
504
  Args:
358
- frame: A numpy array (H, W, C) as returned by cv2.VideoCapture.read()
505
+ frame: One of:
506
+ - numpy ndarray (H, W, C) — from cv2.VideoCapture or picamera2
507
+ - bytes/bytearray — raw JPEG passthrough (zero re-encode), or any
508
+ Pillow-readable format (PNG, BMP, WebP) which is decoded and re-encoded
359
509
  camera_id: Logical camera identifier (e.g. "picam:0", "usb:1")
360
- quality: JPEG compression quality, 1-100. Default 85.
361
- timestamp: Unix timestamp. If not provided, uses current time.
510
+ quality: JPEG compression quality, 1-100. Default 85. Also used as the
511
+ baseline when adaptive downsampling kicks in for oversized frames.
512
+ timestamp: Unix timestamp in seconds. If not provided, uses current time.
362
513
 
363
514
  Returns:
364
515
  True if the frame was sent successfully.
365
516
 
366
517
  Raises:
367
- PlexusError: If transport is not 'ws', or if the send fails.
368
- ImportError: If opencv-python is not installed.
369
-
370
- Example:
371
- cap = cv2.VideoCapture(0)
372
- ret, frame = cap.read()
373
- px.send_video_frame(frame, camera_id="picam:0")
518
+ PlexusError: If transport is not 'ws'.
519
+ ValueError: If frame type is not supported.
520
+ ImportError: If a required optional dependency is missing.
374
521
  """
375
522
  if self.transport != "ws":
376
523
  raise PlexusError("send_video_frame requires transport='ws'")
377
524
 
378
- if self._cv2 is None:
379
- try:
380
- import cv2 as _cv2
381
- self._cv2 = _cv2
382
- except ImportError as e:
383
- raise ImportError(
384
- "send_video_frame requires opencv-python. "
385
- "Install with: pip install opencv-python"
386
- ) from e
387
-
388
- height, width = frame.shape[:2]
389
- _, buf = self._cv2.imencode(".jpg", frame, [self._cv2.IMWRITE_JPEG_QUALITY, quality])
390
- b64 = base64.b64encode(buf).decode()
525
+ jpeg_bytes, width, height = self._encode_frame(frame, quality)
526
+ jpeg_bytes = self._fit_to_wire(jpeg_bytes, quality)
527
+ b64 = base64.b64encode(jpeg_bytes).decode()
391
528
 
392
529
  ws = self._ensure_ws()
393
530
  if not ws.is_authenticated:
@@ -403,6 +540,69 @@ class Plexus:
403
540
  "timestamp": self._normalize_ts_ms(timestamp),
404
541
  })
405
542
 
543
+ def stream_camera(
544
+ self,
545
+ url: str,
546
+ camera_id: str = "camera:0",
547
+ fps: int = 15,
548
+ quality: int = 85,
549
+ ) -> "threading.Event":
550
+ """Stream video from an RTSP URL or file path via FFmpeg (WebSocket only).
551
+
552
+ Requires FFmpeg to be installed and available on $PATH.
553
+
554
+ Args:
555
+ url: RTSP stream URL (rtsp://...), video file path, or any FFmpeg-supported source.
556
+ camera_id: Logical camera identifier forwarded in each frame.
557
+ fps: Maximum frames per second to send. Default 15.
558
+ quality: JPEG quality for re-encoded frames, 1-100. Default 85.
559
+
560
+ Returns:
561
+ A threading.Event. Call .set() on it to stop streaming.
562
+
563
+ Raises:
564
+ PlexusError: If transport is not 'ws' or FFmpeg is not found.
565
+
566
+ Example:
567
+ stop = px.stream_camera("rtsp://192.168.1.100/stream", camera_id="front:0")
568
+ time.sleep(60)
569
+ stop.set()
570
+ """
571
+ if self.transport != "ws":
572
+ raise PlexusError("stream_camera requires transport='ws'")
573
+ if shutil.which("ffmpeg") is None:
574
+ raise PlexusError(
575
+ "FFmpeg not found. Install it: https://ffmpeg.org/download.html"
576
+ )
577
+
578
+ stop_event = threading.Event()
579
+
580
+ def _run():
581
+ cmd = [
582
+ "ffmpeg", "-loglevel", "error",
583
+ "-i", url,
584
+ "-vf", f"fps={fps}",
585
+ "-f", "image2pipe",
586
+ "-vcodec", "mjpeg",
587
+ "pipe:1",
588
+ ]
589
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
590
+ try:
591
+ for jpeg in read_mjpeg_frames(proc.stdout):
592
+ if stop_event.is_set():
593
+ break
594
+ try:
595
+ self.send_video_frame(jpeg, camera_id=camera_id, quality=quality)
596
+ except Exception as e:
597
+ logger.debug("stream_camera send error: %s", e)
598
+ finally:
599
+ proc.terminate()
600
+ proc.wait()
601
+
602
+ t = threading.Thread(target=_run, daemon=True)
603
+ t.start()
604
+ return stop_event
605
+
406
606
  def on_command(
407
607
  self,
408
608
  name: str,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python
3
- Version: 0.4.7
3
+ Version: 0.4.8
4
4
  Summary: Thin Python SDK for Plexus — send telemetry in one line
5
5
  Project-URL: Homepage, https://plexus.dev
6
6
  Project-URL: Documentation, https://docs.plexus.dev
@@ -30,6 +30,8 @@ Requires-Dist: pytest; extra == 'dev'
30
30
  Requires-Dist: pytest-cov; extra == 'dev'
31
31
  Requires-Dist: ruff; extra == 'dev'
32
32
  Requires-Dist: websockets>=12; extra == 'dev'
33
+ Provides-Extra: video
34
+ Requires-Dist: pillow>=9.0; extra == 'video'
33
35
  Description-Content-Type: text/markdown
34
36
 
35
37
  # plexus-python
@@ -0,0 +1,11 @@
1
+ plexus/__init__.py,sha256=TSQdqBAXxoyHxbeAr-UNcqZG411PCmxiM8kpI-hcc2s,385
2
+ plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
+ plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
4
+ plexus/client.py,sha256=qWooAr2-Y8FUr_wpwnjXwdygGuHhPZLCYuVVU2cBziY,33047
5
+ plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
6
+ plexus/ws.py,sha256=4nmNganwS_1BujdFkH3u3lLBB7rEkPYd_oYrbdYkdY4,14818
7
+ plexus_python-0.4.8.dist-info/METADATA,sha256=ssT_r6xkkeT5E6SBx2RWCP008vYhlMqtFMyi1yBRhd4,8188
8
+ plexus_python-0.4.8.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ plexus_python-0.4.8.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
+ plexus_python-0.4.8.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
+ plexus_python-0.4.8.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- plexus/__init__.py,sha256=5Ef1BoSacfFvLTXaSV84bJWMSelmuX43AzO7QRphoys,345
2
- plexus/buffer.py,sha256=3ykybqLs7yMXxQWFajAT8nGe3cs_lW8_6Xvn0vQ69dE,9262
3
- plexus/cli.py,sha256=-2wvHXQzobx3_tDGTXpaE2PlHv884y93Mu29kZE8qZE,14214
4
- plexus/client.py,sha256=uSmgxeGs3BOFfhtnTwH2L-uia-zyRuJTtTYEjyCMl0g,25016
5
- plexus/config.py,sha256=wsG6lhNLmKe3JRlVycyRUKQeywnPUPPfrWkXFxYwELE,6179
6
- plexus/ws.py,sha256=4nmNganwS_1BujdFkH3u3lLBB7rEkPYd_oYrbdYkdY4,14818
7
- plexus_python-0.4.7.dist-info/METADATA,sha256=4tdaBPCjJn2jWzk52vQLSg6zGtq2RrvSU5eF6yanqbI,8121
8
- plexus_python-0.4.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- plexus_python-0.4.7.dist-info/entry_points.txt,sha256=YlkOtTn_7Q_IGuJaKdvpU-90dCeBSPx2p_UTGMAz5Zs,43
10
- plexus_python-0.4.7.dist-info/licenses/LICENSE,sha256=nm3qP1F-JAGcfLpRVtIX24L20LMnRpxmZ2oKZzFpLVo,10755
11
- plexus_python-0.4.7.dist-info/RECORD,,