plexus-python 0.5.1__tar.gz → 0.6.1__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.
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.gitignore +6 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/API.md +1 -1
- {plexus_python-0.5.1 → plexus_python-0.6.1}/CHANGELOG.md +43 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/PKG-INFO +6 -1
- {plexus_python-0.5.1 → plexus_python-0.6.1}/SECURITY.md +2 -2
- plexus_python-0.6.1/examples/thermal_camera.py +88 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/uv.lock +3 -3
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/__init__.py +1 -1
- plexus_python-0.6.1/plexus/_log.py +15 -0
- plexus_python-0.6.1/plexus/cameras/__init__.py +25 -0
- plexus_python-0.6.1/plexus/cameras/thermal.py +388 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/client.py +98 -25
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/ws.py +9 -19
- {plexus_python-0.5.1 → plexus_python-0.6.1}/pyproject.toml +7 -2
- plexus_python-0.6.1/skills/plexus/SKILL.md +189 -0
- plexus_python-0.6.1/skills/plexus/references/api.md +331 -0
- plexus_python-0.6.1/skills/plexus/references/sdk.md +227 -0
- plexus_python-0.6.1/tests/test_basic.py +114 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/tests/test_retry.py +2 -0
- plexus_python-0.6.1/tests/test_thermal.py +153 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/uv.lock +185 -3
- plexus_python-0.5.1/tests/test_basic.py +0 -61
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/AGENTS.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/CONTRIBUTING.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/LICENSE +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/README.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/.python-version +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/README.md +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/basic.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/can.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/mac_metrics.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/mavlink.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/mqtt.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/examples/pyproject.toml +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/buffer.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/cli.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/plexus/config.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/scripts/plexus.service +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/scripts/release.sh +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/scripts/scan_buses.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/scripts/setup.sh +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/tests/test_buffer.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/tests/test_config.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/tests/test_video.py +0 -0
- {plexus_python-0.5.1 → plexus_python-0.6.1}/tests/test_ws.py +0 -0
|
@@ -145,7 +145,7 @@ For real-time UI-controlled streaming, devices connect via WebSocket.
|
|
|
145
145
|
|
|
146
146
|
### Connection Flow
|
|
147
147
|
|
|
148
|
-
1. Device connects to
|
|
148
|
+
1. Device connects to the gateway
|
|
149
149
|
2. Device authenticates with API key
|
|
150
150
|
3. Device reports available sensors
|
|
151
151
|
4. Dashboard controls streaming via messages
|
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.1] - 2026-05-28 - Thermal camera streaming
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `plexus.cameras.thermal` module with hardware-agnostic drivers for I2C sensors
|
|
8
|
+
(MLX90640, MLX90641) and USB cameras (Y16 pixel format). All drivers return a
|
|
9
|
+
unified `ThermalFrame` containing a colorized JPEG plus temperature metadata
|
|
10
|
+
(min, max, mean °C).
|
|
11
|
+
- `ThermalSource.open()` auto-detects connected hardware; pass `"sim"` for a
|
|
12
|
+
simulated source with no physical device required.
|
|
13
|
+
- `client.send_thermal_frame()` sends thermal frames over the existing WebSocket
|
|
14
|
+
connection. Small sensors (≤4096 pixels, e.g. 32×24 MLX90640) include the full
|
|
15
|
+
temperature array inline; larger USB sensors omit it to keep frame sizes manageable.
|
|
16
|
+
- `examples/thermal_camera.py` — end-to-end example for streaming a thermal camera.
|
|
17
|
+
|
|
18
|
+
## [0.5.2] - 2026-05-21 - DX hardening for hardware engineers
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Error messages now reference `plexus init` instead of the non-existent `plexus start`.
|
|
23
|
+
- `close()` now attempts to flush any buffered points before tearing down the transport,
|
|
24
|
+
preventing silent data loss on graceful shutdown.
|
|
25
|
+
- `persistent_buffer` default changed from `False` to `True` — store-and-forward is now
|
|
26
|
+
on by default, matching the `~/.plexus/config.json` default and the right choice for
|
|
27
|
+
field hardware. Pass `persistent_buffer=False` to opt out (e.g. in test fixtures).
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `send_batch()` now accepts 3-tuples `(metric, value, timestamp)` alongside the existing
|
|
32
|
+
2-tuple form. Per-point timestamps let sensors on different interrupt timers share a
|
|
33
|
+
single batch call. 2-tuples continue to use the shared `timestamp` argument.
|
|
34
|
+
- `on_command()` now warns immediately via stderr if called after the WebSocket has already
|
|
35
|
+
authenticated, making the "register before first send()" ordering requirement visible
|
|
36
|
+
rather than silently broken.
|
|
37
|
+
- `source_id` is validated against `^[a-z0-9][a-z0-9_-]{1,62}$` at construction time.
|
|
38
|
+
Invalid names now raise `ValueError` with a clear message instead of failing obscurely
|
|
39
|
+
at the gateway.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- `_say()` / `_QUIET` consolidated into a new internal `plexus/_log.py` module.
|
|
44
|
+
Previously duplicated verbatim between `client.py` and `ws.py`.
|
|
45
|
+
|
|
3
46
|
## [0.5.1] - 2026-05-19 - Binary video frames + non-blocking send
|
|
4
47
|
|
|
5
48
|
### Performance
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexus-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -23,10 +23,15 @@ Classifier: Topic :: System :: Hardware
|
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: websocket-client>=1.7
|
|
25
25
|
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: numpy>=1.24; extra == 'dev'
|
|
27
|
+
Requires-Dist: opencv-python>=4.8; extra == 'dev'
|
|
26
28
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
27
29
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
28
30
|
Requires-Dist: ruff; extra == 'dev'
|
|
29
31
|
Requires-Dist: websockets>=12; extra == 'dev'
|
|
32
|
+
Provides-Extra: thermal
|
|
33
|
+
Requires-Dist: numpy>=1.24; extra == 'thermal'
|
|
34
|
+
Requires-Dist: opencv-python>=4.8; extra == 'thermal'
|
|
30
35
|
Provides-Extra: video
|
|
31
36
|
Requires-Dist: pillow>=12.2.0; extra == 'video'
|
|
32
37
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermal camera streaming to Plexus.
|
|
3
|
+
|
|
4
|
+
Reads from any supported thermal camera and sends frames to Plexus using
|
|
5
|
+
the SDK's send_thermal_frame() method. The gateway relays colorized JPEG
|
|
6
|
+
frames to the app, along with temperature range and (for small sensors)
|
|
7
|
+
per-pixel temperature data.
|
|
8
|
+
|
|
9
|
+
Supported hardware (--camera argument):
|
|
10
|
+
sim Simulated camera — no hardware needed (default)
|
|
11
|
+
mlx90640 MLX90640 32×24 I2C sensor (pip install adafruit-circuitpython-mlx90640)
|
|
12
|
+
mlx90641 MLX90641 16×12 I2C sensor (pip install adafruit-circuitpython-mlx90641)
|
|
13
|
+
usb USB thermal camera in Y16 format (InfiRay, Topdon, Seek, etc.)
|
|
14
|
+
|
|
15
|
+
Run:
|
|
16
|
+
export PLEXUS_API_KEY=plx_xxx
|
|
17
|
+
python thermal_camera.py
|
|
18
|
+
python thermal_camera.py --camera mlx90640
|
|
19
|
+
python thermal_camera.py --camera usb --device 2
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from plexus import Plexus
|
|
27
|
+
from plexus.cameras.thermal import NoCameraFound, ThermalSource
|
|
28
|
+
|
|
29
|
+
CAMERA_ID = "thermal"
|
|
30
|
+
FPS = 5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
parser = argparse.ArgumentParser(description="Stream thermal camera to Plexus.")
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--camera",
|
|
37
|
+
choices=["sim", "mlx90640", "mlx90641", "usb"],
|
|
38
|
+
default=None,
|
|
39
|
+
help="Camera driver (default: auto-detect)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--device",
|
|
43
|
+
type=int,
|
|
44
|
+
default=0,
|
|
45
|
+
help="USB video device index (default: 0)",
|
|
46
|
+
)
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
hint = args.device if args.camera == "usb" else args.camera
|
|
50
|
+
try:
|
|
51
|
+
cam = ThermalSource.open(hint)
|
|
52
|
+
except NoCameraFound as e:
|
|
53
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
px = Plexus(transport="ws")
|
|
57
|
+
px.wait_connected()
|
|
58
|
+
|
|
59
|
+
interval = 1.0 / FPS
|
|
60
|
+
frame_count = 0
|
|
61
|
+
print(f"Streaming {cam.width}×{cam.height} thermal at {FPS} fps — Ctrl-C to stop")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
while True:
|
|
65
|
+
t0 = time.time()
|
|
66
|
+
|
|
67
|
+
temps = cam.read_frame()
|
|
68
|
+
px.send_thermal_frame(temps, camera_id=CAMERA_ID)
|
|
69
|
+
|
|
70
|
+
frame_count += 1
|
|
71
|
+
if frame_count % 50 == 0:
|
|
72
|
+
print(f" {frame_count} frames sent")
|
|
73
|
+
|
|
74
|
+
elapsed = time.time() - t0
|
|
75
|
+
wait = interval - elapsed
|
|
76
|
+
if wait > 0:
|
|
77
|
+
time.sleep(wait)
|
|
78
|
+
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
pass
|
|
81
|
+
finally:
|
|
82
|
+
cam.close()
|
|
83
|
+
px.stop()
|
|
84
|
+
print(f"Done. {frame_count} frames sent.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -674,11 +674,11 @@ wheels = [
|
|
|
674
674
|
|
|
675
675
|
[[package]]
|
|
676
676
|
name = "urllib3"
|
|
677
|
-
version = "2.
|
|
677
|
+
version = "2.7.0"
|
|
678
678
|
source = { registry = "https://pypi.org/simple" }
|
|
679
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
679
|
+
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
|
680
680
|
wheels = [
|
|
681
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
681
|
+
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
|
682
682
|
]
|
|
683
683
|
|
|
684
684
|
[[package]]
|
|
@@ -10,5 +10,5 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
10
10
|
from plexus.client import Plexus, read_mjpeg_frames
|
|
11
11
|
from plexus.ws import WebSocketTransport
|
|
12
12
|
|
|
13
|
-
__version__ = "0.5.
|
|
13
|
+
__version__ = "0.5.2"
|
|
14
14
|
__all__ = ["Plexus", "WebSocketTransport", "read_mjpeg_frames"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
_QUIET = os.environ.get("PLEXUS_QUIET", "").lower() in ("1", "true", "yes")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _say(line: str) -> None:
|
|
8
|
+
"""Single-line status message to stderr. Skipped if PLEXUS_QUIET=1."""
|
|
9
|
+
if _QUIET:
|
|
10
|
+
return
|
|
11
|
+
try:
|
|
12
|
+
sys.stderr.write(f"[plexus] {line}\n")
|
|
13
|
+
sys.stderr.flush()
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from plexus.cameras.thermal import (
|
|
2
|
+
NoCameraFound,
|
|
3
|
+
MLX90640Camera,
|
|
4
|
+
MLX90641Camera,
|
|
5
|
+
SimulatedThermalCamera,
|
|
6
|
+
ThermalCamera,
|
|
7
|
+
ThermalFrame,
|
|
8
|
+
ThermalSource,
|
|
9
|
+
USBThermalCamera,
|
|
10
|
+
build_thermal_frame,
|
|
11
|
+
encode_frame,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"NoCameraFound",
|
|
16
|
+
"MLX90640Camera",
|
|
17
|
+
"MLX90641Camera",
|
|
18
|
+
"SimulatedThermalCamera",
|
|
19
|
+
"ThermalCamera",
|
|
20
|
+
"ThermalFrame",
|
|
21
|
+
"ThermalSource",
|
|
22
|
+
"USBThermalCamera",
|
|
23
|
+
"build_thermal_frame",
|
|
24
|
+
"encode_frame",
|
|
25
|
+
]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermal camera drivers for Plexus.
|
|
3
|
+
|
|
4
|
+
Provides a hardware-agnostic interface over I2C sensors (MLX90640, MLX90641)
|
|
5
|
+
and USB thermal cameras (Y16 pixel format). All drivers return a unified
|
|
6
|
+
ThermalFrame containing a colorized JPEG image plus temperature metadata.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from plexus.cameras.thermal import ThermalSource
|
|
10
|
+
|
|
11
|
+
cam = ThermalSource.open() # auto-detect
|
|
12
|
+
cam = ThermalSource.open("sim") # simulated, no hardware
|
|
13
|
+
|
|
14
|
+
while True:
|
|
15
|
+
px.send_thermal_frame(cam.read_frame(), camera_id="thermal")
|
|
16
|
+
time.sleep(1 / 5)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import time
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
import cv2
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# Inferno is perceptually uniform and well-suited for thermal imaging.
|
|
31
|
+
# Swap for cv2.COLORMAP_HOT or cv2.COLORMAP_JET if preferred.
|
|
32
|
+
_COLORMAP = cv2.COLORMAP_INFERNO
|
|
33
|
+
|
|
34
|
+
# Sensors at or below this pixel count include the full temps array in the
|
|
35
|
+
# wire message. I2C sensors (32×24 = 768, 16×12 = 192) always qualify.
|
|
36
|
+
# USB thermal cameras (256×192 = 49 152) do not.
|
|
37
|
+
_TEMPS_INLINE_THRESHOLD = 4096
|
|
38
|
+
|
|
39
|
+
# Upscale sensors whose shortest side is below this threshold.
|
|
40
|
+
_MIN_DISPLAY_SIDE = 160
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NoCameraFound(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Abstract interface
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ThermalCamera(ABC):
|
|
53
|
+
"""Hardware-agnostic thermal camera interface.
|
|
54
|
+
|
|
55
|
+
Subclass and implement read_frame() to support any sensor.
|
|
56
|
+
read_frame() must return a 2-D float32 array of temperatures in Celsius,
|
|
57
|
+
shaped (height, width).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def width(self) -> int: ...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def height(self) -> int: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def read_frame(self) -> np.ndarray: ...
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Unified output
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ThermalFrame:
|
|
82
|
+
"""Encoded output from a ThermalCamera, ready to send to the gateway."""
|
|
83
|
+
|
|
84
|
+
image: np.ndarray # uint8 BGR colorized image (display size)
|
|
85
|
+
width: int # display width (may differ from sensor if upscaled)
|
|
86
|
+
height: int # display height
|
|
87
|
+
sensor_width: int # native sensor width
|
|
88
|
+
sensor_height: int # native sensor height
|
|
89
|
+
temp_min: float
|
|
90
|
+
temp_max: float
|
|
91
|
+
temps: Optional[np.ndarray] # native res; None when sensor > threshold
|
|
92
|
+
timestamp_ms: int
|
|
93
|
+
|
|
94
|
+
def to_message(
|
|
95
|
+
self, camera_id: str, source_id: Optional[str] = None, quality: int = 85
|
|
96
|
+
) -> Dict[str, Any]:
|
|
97
|
+
"""Build the gateway `video_frame` wire message for this frame."""
|
|
98
|
+
_, buf = cv2.imencode(".jpg", self.image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
|
99
|
+
b64 = base64.b64encode(buf.tobytes()).decode("ascii")
|
|
100
|
+
|
|
101
|
+
msg: Dict[str, Any] = {
|
|
102
|
+
"type": "video_frame",
|
|
103
|
+
"camera_id": camera_id,
|
|
104
|
+
"frame": b64,
|
|
105
|
+
"width": self.width,
|
|
106
|
+
"height": self.height,
|
|
107
|
+
"timestamp": self.timestamp_ms,
|
|
108
|
+
"video_type": "thermal",
|
|
109
|
+
"sensor_width": self.sensor_width,
|
|
110
|
+
"sensor_height": self.sensor_height,
|
|
111
|
+
"temp_min": self.temp_min,
|
|
112
|
+
"temp_max": self.temp_max,
|
|
113
|
+
}
|
|
114
|
+
if source_id is not None:
|
|
115
|
+
msg["source_id"] = source_id
|
|
116
|
+
if self.temps is not None:
|
|
117
|
+
msg["temps"] = self.temps.flatten().tolist()
|
|
118
|
+
return msg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Encoding — single shared path used by both encode_frame() and the SDK's
|
|
123
|
+
# Plexus.send_thermal_frame().
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _upscale_size(sw: int, sh: int) -> tuple[int, int]:
|
|
128
|
+
if sw >= _MIN_DISPLAY_SIDE and sh >= _MIN_DISPLAY_SIDE:
|
|
129
|
+
return sw, sh
|
|
130
|
+
scale = max(_MIN_DISPLAY_SIDE / sw, _MIN_DISPLAY_SIDE / sh)
|
|
131
|
+
return round(sw * scale), round(sh * scale)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_thermal_frame(
|
|
135
|
+
temps: np.ndarray, timestamp_ms: Optional[int] = None
|
|
136
|
+
) -> ThermalFrame:
|
|
137
|
+
"""Colorize a temperature array into a ThermalFrame.
|
|
138
|
+
|
|
139
|
+
temps: 2-D float32 Celsius array, shape (height, width).
|
|
140
|
+
timestamp_ms: wire timestamp; defaults to current time.
|
|
141
|
+
"""
|
|
142
|
+
sh, sw = temps.shape[:2]
|
|
143
|
+
|
|
144
|
+
temp_min = float(np.min(temps))
|
|
145
|
+
temp_max = float(np.max(temps))
|
|
146
|
+
span = temp_max - temp_min
|
|
147
|
+
|
|
148
|
+
normalized = (
|
|
149
|
+
np.zeros((sh, sw), dtype=np.uint8)
|
|
150
|
+
if span < 1e-6
|
|
151
|
+
else ((temps - temp_min) / span * 255).astype(np.uint8)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
colored = cv2.applyColorMap(normalized, _COLORMAP)
|
|
155
|
+
|
|
156
|
+
dw, dh = _upscale_size(sw, sh)
|
|
157
|
+
if (dw, dh) != (sw, sh):
|
|
158
|
+
colored = cv2.resize(colored, (dw, dh), interpolation=cv2.INTER_CUBIC)
|
|
159
|
+
|
|
160
|
+
return ThermalFrame(
|
|
161
|
+
image=colored,
|
|
162
|
+
width=dw,
|
|
163
|
+
height=dh,
|
|
164
|
+
sensor_width=sw,
|
|
165
|
+
sensor_height=sh,
|
|
166
|
+
temp_min=round(temp_min, 2),
|
|
167
|
+
temp_max=round(temp_max, 2),
|
|
168
|
+
temps=temps if sw * sh <= _TEMPS_INLINE_THRESHOLD else None,
|
|
169
|
+
timestamp_ms=timestamp_ms if timestamp_ms is not None else int(time.time() * 1000),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def encode_frame(cam: ThermalCamera) -> ThermalFrame:
|
|
174
|
+
"""Read one frame from a camera and colorize it."""
|
|
175
|
+
return build_thermal_frame(cam.read_frame())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Drivers
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SimulatedThermalCamera(ThermalCamera):
|
|
184
|
+
"""Simulated thermal camera for testing without hardware.
|
|
185
|
+
|
|
186
|
+
Produces a moving warm blob over a noisy background at 32×24.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, width: int = 32, height: int = 24) -> None:
|
|
190
|
+
self._width = width
|
|
191
|
+
self._height = height
|
|
192
|
+
self._t = 0.0
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def width(self) -> int:
|
|
196
|
+
return self._width
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def height(self) -> int:
|
|
200
|
+
return self._height
|
|
201
|
+
|
|
202
|
+
def read_frame(self) -> np.ndarray:
|
|
203
|
+
self._t += 0.1
|
|
204
|
+
x = np.linspace(0, 2 * np.pi, self._width)
|
|
205
|
+
y = np.linspace(0, 2 * np.pi, self._height)
|
|
206
|
+
xx, yy = np.meshgrid(x, y)
|
|
207
|
+
base = 22.0 + 3.0 * np.sin(xx + self._t) * np.cos(yy + self._t * 0.7)
|
|
208
|
+
cx = int((np.sin(self._t * 0.5) * 0.4 + 0.5) * self._width)
|
|
209
|
+
cy = int((np.cos(self._t * 0.3) * 0.4 + 0.5) * self._height)
|
|
210
|
+
yg, xg = np.ogrid[: self._height, : self._width]
|
|
211
|
+
hotspot = 15.0 * np.exp(-((xg - cx) ** 2 + (yg - cy) ** 2) / 20.0)
|
|
212
|
+
return (base + hotspot).astype(np.float32)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class USBThermalCamera(ThermalCamera):
|
|
216
|
+
"""USB thermal camera via V4L2/UVC in Y16 pixel format.
|
|
217
|
+
|
|
218
|
+
Most USB thermal cameras (InfiRay, Topdon, Seek, some FLIR) present as
|
|
219
|
+
standard UVC video devices outputting Y16 frames where each uint16 pixel
|
|
220
|
+
encodes temperature as: celsius = (value / 100.0) - 273.15
|
|
221
|
+
|
|
222
|
+
Requires: pip install opencv-python
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
_KELVIN_SCALE = 100.0
|
|
226
|
+
_KELVIN_OFFSET = 273.15
|
|
227
|
+
|
|
228
|
+
def __init__(self, device_index: int = 0) -> None:
|
|
229
|
+
self._cap = cv2.VideoCapture(device_index)
|
|
230
|
+
if not self._cap.isOpened():
|
|
231
|
+
raise NoCameraFound(f"Cannot open video device {device_index}")
|
|
232
|
+
self._cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"Y16 "))
|
|
233
|
+
ret, frame = self._cap.read()
|
|
234
|
+
if not ret or frame is None:
|
|
235
|
+
self._cap.release()
|
|
236
|
+
raise NoCameraFound(
|
|
237
|
+
f"Device {device_index} opened but could not read a frame. "
|
|
238
|
+
"Check that it supports Y16 format."
|
|
239
|
+
)
|
|
240
|
+
self._width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
241
|
+
self._height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def width(self) -> int:
|
|
245
|
+
return self._width
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def height(self) -> int:
|
|
249
|
+
return self._height
|
|
250
|
+
|
|
251
|
+
def read_frame(self) -> np.ndarray:
|
|
252
|
+
ret, raw = self._cap.read()
|
|
253
|
+
if not ret or raw is None:
|
|
254
|
+
raise RuntimeError("Failed to read frame from USB thermal camera")
|
|
255
|
+
u16 = raw.view(np.uint16).reshape(self._height, self._width)
|
|
256
|
+
return (u16.astype(np.float32) / self._KELVIN_SCALE) - self._KELVIN_OFFSET
|
|
257
|
+
|
|
258
|
+
def close(self) -> None:
|
|
259
|
+
self._cap.release()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MLX90640Camera(ThermalCamera):
|
|
263
|
+
"""MLX90640 32×24 thermal array via I2C.
|
|
264
|
+
|
|
265
|
+
Requires: pip install adafruit-circuitpython-mlx90640
|
|
266
|
+
Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self) -> None:
|
|
270
|
+
import adafruit_mlx90640
|
|
271
|
+
import board
|
|
272
|
+
import busio
|
|
273
|
+
|
|
274
|
+
i2c = busio.I2C(board.SCL, board.SDA, frequency=800_000)
|
|
275
|
+
self._mlx = adafruit_mlx90640.MLX90640(i2c)
|
|
276
|
+
self._mlx.refresh_rate = adafruit_mlx90640.RefreshRate.REFRESH_4_HZ
|
|
277
|
+
self._buf = [0.0] * 768
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def width(self) -> int:
|
|
281
|
+
return 32
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def height(self) -> int:
|
|
285
|
+
return 24
|
|
286
|
+
|
|
287
|
+
def read_frame(self) -> np.ndarray:
|
|
288
|
+
self._mlx.getFrame(self._buf)
|
|
289
|
+
return np.array(self._buf, dtype=np.float32).reshape(24, 32)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class MLX90641Camera(ThermalCamera):
|
|
293
|
+
"""MLX90641 16×12 thermal array via I2C.
|
|
294
|
+
|
|
295
|
+
Requires: pip install adafruit-circuitpython-mlx90641
|
|
296
|
+
Wiring: SCL → GPIO 3, SDA → GPIO 2, 3.3V power (Raspberry Pi).
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self) -> None:
|
|
300
|
+
import adafruit_mlx90641
|
|
301
|
+
import board
|
|
302
|
+
import busio
|
|
303
|
+
|
|
304
|
+
i2c = busio.I2C(board.SCL, board.SDA, frequency=400_000)
|
|
305
|
+
self._mlx = adafruit_mlx90641.MLX90641(i2c)
|
|
306
|
+
self._mlx.refresh_rate = adafruit_mlx90641.RefreshRate.REFRESH_4_HZ
|
|
307
|
+
self._buf = [0.0] * 192
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def width(self) -> int:
|
|
311
|
+
return 16
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def height(self) -> int:
|
|
315
|
+
return 12
|
|
316
|
+
|
|
317
|
+
def read_frame(self) -> np.ndarray:
|
|
318
|
+
self._mlx.getFrame(self._buf)
|
|
319
|
+
return np.array(self._buf, dtype=np.float32).reshape(12, 16)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Auto-detection factory
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ThermalSource:
|
|
328
|
+
"""Factory that opens the first available thermal camera.
|
|
329
|
+
|
|
330
|
+
Examples:
|
|
331
|
+
ThermalSource.open() # auto-detect
|
|
332
|
+
ThermalSource.open("sim") # simulated
|
|
333
|
+
ThermalSource.open("mlx90640") # force I2C MLX90640
|
|
334
|
+
ThermalSource.open("mlx90641") # force I2C MLX90641
|
|
335
|
+
ThermalSource.open("usb") # USB thermal at index 0
|
|
336
|
+
ThermalSource.open(2) # USB thermal at index 2
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def open(hint: str | int | None = None) -> ThermalCamera:
|
|
341
|
+
if hint == "sim":
|
|
342
|
+
return SimulatedThermalCamera()
|
|
343
|
+
if hint == "mlx90640":
|
|
344
|
+
return MLX90640Camera()
|
|
345
|
+
if hint == "mlx90641":
|
|
346
|
+
return MLX90641Camera()
|
|
347
|
+
if hint == "usb" or isinstance(hint, int):
|
|
348
|
+
return USBThermalCamera(0 if hint == "usb" else hint)
|
|
349
|
+
return ThermalSource._autodetect()
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _autodetect() -> ThermalCamera:
|
|
353
|
+
cam = ThermalSource._try_i2c()
|
|
354
|
+
if cam:
|
|
355
|
+
return cam
|
|
356
|
+
cam = ThermalSource._try_usb()
|
|
357
|
+
if cam:
|
|
358
|
+
return cam
|
|
359
|
+
raise NoCameraFound(
|
|
360
|
+
"No thermal camera detected. Use ThermalSource.open('sim') to run without hardware."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _try_i2c() -> ThermalCamera | None:
|
|
365
|
+
try:
|
|
366
|
+
import board
|
|
367
|
+
import busio
|
|
368
|
+
|
|
369
|
+
i2c = busio.I2C(board.SCL, board.SDA)
|
|
370
|
+
i2c.try_lock()
|
|
371
|
+
addrs = i2c.scan()
|
|
372
|
+
i2c.unlock()
|
|
373
|
+
if 0x33 in addrs:
|
|
374
|
+
return MLX90640Camera()
|
|
375
|
+
if 0x60 in addrs:
|
|
376
|
+
return MLX90641Camera()
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _try_usb() -> ThermalCamera | None:
|
|
383
|
+
for idx in range(10):
|
|
384
|
+
try:
|
|
385
|
+
return USBThermalCamera(idx)
|
|
386
|
+
except NoCameraFound:
|
|
387
|
+
continue
|
|
388
|
+
return None
|