remla 0.3.2__tar.gz → 0.3.3.dev1__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.
- {remla-0.3.2 → remla-0.3.3.dev1}/PKG-INFO +1 -1
- {remla-0.3.2 → remla-0.3.3.dev1}/pyproject.toml +1 -1
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/Controllers.py +276 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/__init__.py +1 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediamtx.yml +1 -1
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/systemHelpers.py +2 -3
- {remla-0.3.2 → remla-0.3.3.dev1}/README.md +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/__init__.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/customvalidators.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/i2ccmd.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/Experiment.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/test.json +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/main.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/settings.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/finalInfoTemplate.md +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/hello.txt +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/i2c-output.png +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/index.html +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/localhost.conf +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediaMTXGetFeed.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediamtx.service +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/reader.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_BACKUP_5176.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_BASE_5176.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_LOCAL_5176.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_REMOTE_5176.js +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/setupcmd.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/typerHelpers.py +0 -0
- {remla-0.3.2 → remla-0.3.3.dev1}/remla/yaml.py +0 -0
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import subprocess
|
|
4
4
|
import sys
|
|
5
5
|
import time
|
|
6
|
+
import shutil
|
|
6
7
|
from abc import ABC, ABCMeta, abstractmethod
|
|
7
8
|
from warnings import warn
|
|
8
9
|
|
|
@@ -1504,6 +1505,281 @@ class ArduCamMultiCamera(BaseController):
|
|
|
1504
1505
|
time.sleep(0.1)
|
|
1505
1506
|
|
|
1506
1507
|
|
|
1508
|
+
class PiCamera2MultiCam(BaseController):
|
|
1509
|
+
deviceType = "measurement"
|
|
1510
|
+
|
|
1511
|
+
CONTROL_MAPPINGS = {
|
|
1512
|
+
"brightness": "Brightness",
|
|
1513
|
+
"contrast": "Contrast",
|
|
1514
|
+
"saturation": "Saturation",
|
|
1515
|
+
"sharpness": "Sharpness",
|
|
1516
|
+
"analoguegain": "AnalogueGain",
|
|
1517
|
+
"gain": "AnalogueGain",
|
|
1518
|
+
"exposuretime": "ExposureTime",
|
|
1519
|
+
"exposure_time": "ExposureTime",
|
|
1520
|
+
"exposure": "ExposureTime",
|
|
1521
|
+
"awb": "AwbEnable",
|
|
1522
|
+
"awbenable": "AwbEnable",
|
|
1523
|
+
"colourgains": "ColourGains",
|
|
1524
|
+
"awb_gains": "ColourGains",
|
|
1525
|
+
"aeenable": "AeEnable",
|
|
1526
|
+
"autoexposure": "AeEnable",
|
|
1527
|
+
"lensposition": "LensPosition",
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
def __init__(
|
|
1531
|
+
self,
|
|
1532
|
+
name,
|
|
1533
|
+
numCameras,
|
|
1534
|
+
videoNumber=0,
|
|
1535
|
+
defaultSettings=None,
|
|
1536
|
+
i2cbus=11,
|
|
1537
|
+
initialCamera="a",
|
|
1538
|
+
controlPins=[4, 17, 18],
|
|
1539
|
+
cameraNamesDict=None,
|
|
1540
|
+
streamPath="cam",
|
|
1541
|
+
streamUrl=None,
|
|
1542
|
+
width=1920,
|
|
1543
|
+
height=1080,
|
|
1544
|
+
fps=30,
|
|
1545
|
+
bitrate=5000000,
|
|
1546
|
+
hflip=False,
|
|
1547
|
+
vflip=False,
|
|
1548
|
+
):
|
|
1549
|
+
super().__init__(name)
|
|
1550
|
+
self.videoNumber = videoNumber
|
|
1551
|
+
self.numCameras = numCameras
|
|
1552
|
+
self.defaultSettings = defaultSettings or {}
|
|
1553
|
+
self.i2cbus = i2cbus
|
|
1554
|
+
self.cameraNames = cameraNamesDict or {}
|
|
1555
|
+
self.initialCamera = initialCamera
|
|
1556
|
+
self.streamPath = streamPath
|
|
1557
|
+
self.streamUrl = streamUrl or f"rtsp://127.0.0.1:8554/{streamPath}"
|
|
1558
|
+
self.width = width
|
|
1559
|
+
self.height = height
|
|
1560
|
+
self.fps = fps
|
|
1561
|
+
self.bitrate = bitrate
|
|
1562
|
+
self.hflip = hflip
|
|
1563
|
+
self.vflip = vflip
|
|
1564
|
+
self.selection, self.enable1, self.enable2 = controlPins
|
|
1565
|
+
self.channels = controlPins
|
|
1566
|
+
gpio.setup(self.channels, gpio.OUT)
|
|
1567
|
+
from remla.systemHelpers import get_camera_logger
|
|
1568
|
+
|
|
1569
|
+
self.logger = get_camera_logger()
|
|
1570
|
+
|
|
1571
|
+
self.cameraDict = {
|
|
1572
|
+
"a": (gpio.LOW, gpio.LOW, gpio.HIGH),
|
|
1573
|
+
"b": (gpio.HIGH, gpio.LOW, gpio.HIGH),
|
|
1574
|
+
"c": (gpio.LOW, gpio.HIGH, gpio.LOW),
|
|
1575
|
+
"d": (gpio.HIGH, gpio.HIGH, gpio.LOW),
|
|
1576
|
+
"off": (gpio.LOW, gpio.HIGH, gpio.HIGH),
|
|
1577
|
+
}
|
|
1578
|
+
self.slot_order = ["a", "b", "c", "d"]
|
|
1579
|
+
self.active_slot = None
|
|
1580
|
+
self.picam2 = None
|
|
1581
|
+
self.encoder = None
|
|
1582
|
+
self.output = None
|
|
1583
|
+
|
|
1584
|
+
self._runtime = None
|
|
1585
|
+
self._ensure_runtime()
|
|
1586
|
+
self._start_camera(self._resolve_camera_param(initialCamera), apply_defaults=True)
|
|
1587
|
+
self.state["camera"] = self.active_slot
|
|
1588
|
+
|
|
1589
|
+
def _ensure_runtime(self):
|
|
1590
|
+
if self._runtime is not None:
|
|
1591
|
+
return self._runtime
|
|
1592
|
+
if shutil.which("ffmpeg") is None:
|
|
1593
|
+
raise RuntimeError("ffmpeg is required to publish Picamera2 output to MediaMTX")
|
|
1594
|
+
|
|
1595
|
+
try:
|
|
1596
|
+
from libcamera import Transform
|
|
1597
|
+
from picamera2 import Picamera2
|
|
1598
|
+
from picamera2.encoders import H264Encoder
|
|
1599
|
+
from picamera2.outputs import FfmpegOutput
|
|
1600
|
+
except Exception as exc:
|
|
1601
|
+
raise RuntimeError(
|
|
1602
|
+
"PiCamera2MultiCam requires Picamera2/libcamera packages on the Raspberry Pi runtime"
|
|
1603
|
+
) from exc
|
|
1604
|
+
|
|
1605
|
+
self._runtime = {
|
|
1606
|
+
"Picamera2": Picamera2,
|
|
1607
|
+
"H264Encoder": H264Encoder,
|
|
1608
|
+
"FfmpegOutput": FfmpegOutput,
|
|
1609
|
+
"Transform": Transform,
|
|
1610
|
+
}
|
|
1611
|
+
return self._runtime
|
|
1612
|
+
|
|
1613
|
+
def _coerce_bool(self, value):
|
|
1614
|
+
if isinstance(value, bool):
|
|
1615
|
+
return value
|
|
1616
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
1617
|
+
|
|
1618
|
+
def _coerce_control_value(self, control_name, value):
|
|
1619
|
+
if control_name in {"AwbEnable", "AeEnable"}:
|
|
1620
|
+
return self._coerce_bool(value)
|
|
1621
|
+
if control_name == "ColourGains":
|
|
1622
|
+
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
1623
|
+
return (float(value[0]), float(value[1]))
|
|
1624
|
+
pieces = [part.strip() for part in str(value).split(",")]
|
|
1625
|
+
if len(pieces) != 2:
|
|
1626
|
+
raise ValueError("ColourGains expects two comma-separated values")
|
|
1627
|
+
return (float(pieces[0]), float(pieces[1]))
|
|
1628
|
+
try:
|
|
1629
|
+
if "." in str(value):
|
|
1630
|
+
return float(value)
|
|
1631
|
+
return int(value)
|
|
1632
|
+
except ValueError:
|
|
1633
|
+
return value
|
|
1634
|
+
|
|
1635
|
+
def _normalize_control_name(self, control_name):
|
|
1636
|
+
key = str(control_name).strip()
|
|
1637
|
+
normalized = self.CONTROL_MAPPINGS.get(key.lower())
|
|
1638
|
+
if normalized is not None:
|
|
1639
|
+
return normalized
|
|
1640
|
+
if key and key[0].isupper():
|
|
1641
|
+
return key
|
|
1642
|
+
raise ValueError(f"Unsupported camera control '{control_name}'")
|
|
1643
|
+
|
|
1644
|
+
def _resolve_camera_param(self, param):
|
|
1645
|
+
lowered = str(param).lower()
|
|
1646
|
+
if lowered in self.slot_order:
|
|
1647
|
+
return lowered
|
|
1648
|
+
if lowered == "off":
|
|
1649
|
+
return "off"
|
|
1650
|
+
if lowered in self.cameraNames:
|
|
1651
|
+
return self.cameraNames[lowered]
|
|
1652
|
+
raise ValueError(f"Unknown camera selection '{param}'")
|
|
1653
|
+
|
|
1654
|
+
def _select_slot(self, slot):
|
|
1655
|
+
if slot == "off":
|
|
1656
|
+
gpio.output(self.channels, self.cameraDict["off"])
|
|
1657
|
+
self.active_slot = "off"
|
|
1658
|
+
return
|
|
1659
|
+
|
|
1660
|
+
try:
|
|
1661
|
+
index = self.slot_order.index(slot)
|
|
1662
|
+
except ValueError as exc:
|
|
1663
|
+
raise ValueError(f"Unknown camera slot '{slot}'") from exc
|
|
1664
|
+
|
|
1665
|
+
from remla.systemHelpers import select_arducam_channel_index
|
|
1666
|
+
|
|
1667
|
+
ok = select_arducam_channel_index(
|
|
1668
|
+
index,
|
|
1669
|
+
bus=self.i2cbus,
|
|
1670
|
+
control_pins=list(self.channels),
|
|
1671
|
+
)
|
|
1672
|
+
if not ok:
|
|
1673
|
+
raise RuntimeError(f"Failed to select ArduCam channel '{slot}'")
|
|
1674
|
+
gpio.output(self.channels, self.cameraDict[slot])
|
|
1675
|
+
self.active_slot = slot
|
|
1676
|
+
|
|
1677
|
+
def _build_video_config(self):
|
|
1678
|
+
runtime = self._ensure_runtime()
|
|
1679
|
+
controls = {"FrameRate": self.fps}
|
|
1680
|
+
return self.picam2.create_video_configuration(
|
|
1681
|
+
main={"size": (self.width, self.height)},
|
|
1682
|
+
controls=controls,
|
|
1683
|
+
transform=runtime["Transform"](hflip=self.hflip, vflip=self.vflip),
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
def _release_camera(self):
|
|
1687
|
+
if self.picam2 is None:
|
|
1688
|
+
return
|
|
1689
|
+
try:
|
|
1690
|
+
self.picam2.stop_recording()
|
|
1691
|
+
except Exception:
|
|
1692
|
+
self.logger.debug("Picamera2 stop_recording skipped", exc_info=True)
|
|
1693
|
+
try:
|
|
1694
|
+
self.picam2.close()
|
|
1695
|
+
except Exception:
|
|
1696
|
+
self.logger.debug("Picamera2 close skipped", exc_info=True)
|
|
1697
|
+
self.picam2 = None
|
|
1698
|
+
self.encoder = None
|
|
1699
|
+
self.output = None
|
|
1700
|
+
|
|
1701
|
+
def _start_camera(self, slot, apply_defaults=False):
|
|
1702
|
+
runtime = self._ensure_runtime()
|
|
1703
|
+
self._release_camera()
|
|
1704
|
+
|
|
1705
|
+
if slot == "off":
|
|
1706
|
+
self._select_slot("off")
|
|
1707
|
+
self.state["camera"] = self.active_slot
|
|
1708
|
+
return
|
|
1709
|
+
|
|
1710
|
+
self._select_slot(slot)
|
|
1711
|
+
time.sleep(0.2)
|
|
1712
|
+
|
|
1713
|
+
picam_cls = runtime["Picamera2"]
|
|
1714
|
+
self.picam2 = picam_cls(self.videoNumber) if self.videoNumber else picam_cls()
|
|
1715
|
+
self.picam2.configure(self._build_video_config())
|
|
1716
|
+
self.encoder = runtime["H264Encoder"](bitrate=self.bitrate)
|
|
1717
|
+
output_args = f"-f rtsp -rtsp_transport tcp {self.streamUrl}"
|
|
1718
|
+
self.output = runtime["FfmpegOutput"](output_args)
|
|
1719
|
+
self.picam2.start_recording(self.encoder, self.output)
|
|
1720
|
+
if apply_defaults and self.defaultSettings:
|
|
1721
|
+
self._apply_controls(self.defaultSettings)
|
|
1722
|
+
self.state["camera"] = self.active_slot
|
|
1723
|
+
|
|
1724
|
+
def _apply_controls(self, controls):
|
|
1725
|
+
if self.picam2 is None:
|
|
1726
|
+
raise RuntimeError("Camera is not active")
|
|
1727
|
+
translated = {}
|
|
1728
|
+
for control_name, value in controls.items():
|
|
1729
|
+
normalized_name = self._normalize_control_name(control_name)
|
|
1730
|
+
translated[normalized_name] = self._coerce_control_value(normalized_name, value)
|
|
1731
|
+
self.picam2.set_controls(translated)
|
|
1732
|
+
self.state["controls"] = {**self.state.get("controls", {}), **translated}
|
|
1733
|
+
|
|
1734
|
+
def camera(self, param):
|
|
1735
|
+
slot = self._resolve_camera_param(param)
|
|
1736
|
+
print("Switching to camera " + slot)
|
|
1737
|
+
self._start_camera(slot, apply_defaults=True)
|
|
1738
|
+
self.state["camera"] = slot
|
|
1739
|
+
|
|
1740
|
+
def camera_parser(self, params):
|
|
1741
|
+
if len(params) != 1:
|
|
1742
|
+
raise ArgumentNumberError(len(params), 1, "camera")
|
|
1743
|
+
param = params[0].lower()
|
|
1744
|
+
if param not in self.cameraDict:
|
|
1745
|
+
raise ArgumentError(self.name, "camera", param, ["a", "b", "c", "d", "off"])
|
|
1746
|
+
return param
|
|
1747
|
+
|
|
1748
|
+
def cameraName(self, param):
|
|
1749
|
+
key = str(param).lower()
|
|
1750
|
+
if key not in self.cameraNames:
|
|
1751
|
+
raise ArgumentError(self.name, "cameraName", param, self.cameraNames)
|
|
1752
|
+
slot = self.cameraNames[key]
|
|
1753
|
+
print("Switching to camera {0}, slot {1}".format(key, slot))
|
|
1754
|
+
self._start_camera(slot, apply_defaults=True)
|
|
1755
|
+
self.state["camera"] = slot
|
|
1756
|
+
|
|
1757
|
+
def cameraName_parser(self, params):
|
|
1758
|
+
if len(params) != 1:
|
|
1759
|
+
raise ArgumentNumberError(len(params), 1, "cameraName")
|
|
1760
|
+
param = params[0].lower()
|
|
1761
|
+
if param not in self.cameraNames:
|
|
1762
|
+
raise ArgumentError(self.name, "cameraName", param, self.cameraNames)
|
|
1763
|
+
return param
|
|
1764
|
+
|
|
1765
|
+
def imageMod(self, params):
|
|
1766
|
+
control_name = self._normalize_control_name(params[0])
|
|
1767
|
+
control_value = self._coerce_control_value(control_name, params[1])
|
|
1768
|
+
self._apply_controls({control_name: control_value})
|
|
1769
|
+
|
|
1770
|
+
def imageMod_parser(self, params):
|
|
1771
|
+
if len(params) != 2:
|
|
1772
|
+
raise ArgumentNumberError(len(params), 2, "imageMod")
|
|
1773
|
+
return params
|
|
1774
|
+
|
|
1775
|
+
def reset(self):
|
|
1776
|
+
self._start_camera(self._resolve_camera_param(self.initialCamera), apply_defaults=True)
|
|
1777
|
+
self.state["camera"] = self.active_slot
|
|
1778
|
+
|
|
1779
|
+
def close(self):
|
|
1780
|
+
self._release_camera()
|
|
1781
|
+
|
|
1782
|
+
|
|
1507
1783
|
class ElectronicScreen(BaseController):
|
|
1508
1784
|
deviceType = "controller"
|
|
1509
1785
|
|
|
@@ -415,11 +415,11 @@ def cycle_initialize_cameras(timeout_per_camera: int = 4) -> None:
|
|
|
415
415
|
device_settings = lab_settings.get("devices", {})
|
|
416
416
|
camera_cfg = None
|
|
417
417
|
for device in device_settings.values():
|
|
418
|
-
if device.get("type")
|
|
418
|
+
if device.get("type") in {"ArduCamMultiCamera", "PiCamera2MultiCam"}:
|
|
419
419
|
camera_cfg = device
|
|
420
420
|
break
|
|
421
421
|
if camera_cfg is None:
|
|
422
|
-
logger.info("No
|
|
422
|
+
logger.info("No supported multi-camera device configured in lab settings; skipping camera cycling.")
|
|
423
423
|
return
|
|
424
424
|
|
|
425
425
|
numCameras = camera_cfg.get("numCameras", 0)
|
|
@@ -477,4 +477,3 @@ def get_boot_status() -> bool:
|
|
|
477
477
|
runMarker.write_text(str(current_boot))
|
|
478
478
|
logger.error("Boot record file missing; assuming reboot.")
|
|
479
479
|
return True
|
|
480
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|