Camera live view (test): MJPEG over TCP from tool/camview_server.py, exposure and gain controls
Its own paced connection beside the boundary (project-yukart decision 27, proposed); latest-wins per client, SO_MAX_PACING_RATE 4 MB/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
// Camera live view, for testing: shows the Orin camera's MJPEG frames as they
|
||||||
|
// arrive, with exposure and gain controls. Pairs with tool/camview_server.py.
|
||||||
|
// Not the boundary: its own connection (project-yukart decision 27, proposed).
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const camviewPort = 7710;
|
||||||
|
|
||||||
|
class CamView extends ChangeNotifier {
|
||||||
|
CamView(this.host);
|
||||||
|
final String host;
|
||||||
|
|
||||||
|
Socket? _s;
|
||||||
|
bool _running = false;
|
||||||
|
String status = 'not started';
|
||||||
|
Uint8List? frame;
|
||||||
|
int frames = 0;
|
||||||
|
double fps = 0, kbPerFrame = 0, mbPerS = 0;
|
||||||
|
|
||||||
|
/// Delay beyond the fastest of the last ~300 frames: queueing, independent of the
|
||||||
|
/// tens of ms by which the Orin's and the tablet's clocks disagree.
|
||||||
|
double lagMs = 0;
|
||||||
|
final _delays = <double>[];
|
||||||
|
String lastReply = '';
|
||||||
|
|
||||||
|
final _window = <(int, int)>[]; // (arrival µs, bytes) over the last second
|
||||||
|
final _clock = Stopwatch()..start();
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
_loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
_running = false;
|
||||||
|
_s?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(String line) => _s?.write('$line\n');
|
||||||
|
|
||||||
|
Future<void> _loop() async {
|
||||||
|
while (_running) {
|
||||||
|
status = 'connecting to $host:$camviewPort';
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final s = await Socket.connect(
|
||||||
|
host,
|
||||||
|
camviewPort,
|
||||||
|
timeout: const Duration(seconds: 3),
|
||||||
|
);
|
||||||
|
_s = s;
|
||||||
|
status = 'connected';
|
||||||
|
notifyListeners();
|
||||||
|
await _read(s);
|
||||||
|
status = 'closed';
|
||||||
|
} catch (e) {
|
||||||
|
status = '$e'.split('\n').first;
|
||||||
|
}
|
||||||
|
_s = null;
|
||||||
|
notifyListeners();
|
||||||
|
if (_running) await Future<void>.delayed(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _read(Socket s) async {
|
||||||
|
var data = Uint8List(0); // unconsumed bytes
|
||||||
|
await for (final chunk in s) {
|
||||||
|
if (data.isEmpty) {
|
||||||
|
data = chunk;
|
||||||
|
} else {
|
||||||
|
data =
|
||||||
|
(BytesBuilder(copy: false)
|
||||||
|
..add(data)
|
||||||
|
..add(chunk))
|
||||||
|
.takeBytes();
|
||||||
|
}
|
||||||
|
var off = 0;
|
||||||
|
while (data.length - off >= 16) {
|
||||||
|
final h = ByteData.sublistView(data, off, off + 16);
|
||||||
|
final len = h.getUint32(0);
|
||||||
|
final sentAt = h.getFloat64(4);
|
||||||
|
final body = len == 0
|
||||||
|
? h.getUint32(12)
|
||||||
|
: len; // length 0: a text reply, its length in the third field
|
||||||
|
if (data.length - off - 16 < body) break;
|
||||||
|
final bytes = Uint8List.sublistView(data, off + 16, off + 16 + body);
|
||||||
|
off += 16 + body;
|
||||||
|
if (len == 0) {
|
||||||
|
lastReply = utf8.decode(bytes);
|
||||||
|
} else {
|
||||||
|
_onFrame(Uint8List.fromList(bytes), sentAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = Uint8List.sublistView(data, off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFrame(Uint8List jpeg, double sentAt) {
|
||||||
|
final now = _clock.elapsedMicroseconds;
|
||||||
|
_window.add((now, jpeg.length));
|
||||||
|
_window.removeWhere((e) => now - e.$1 > 1000000);
|
||||||
|
frames++;
|
||||||
|
fps = _window.length.toDouble();
|
||||||
|
final bytes = _window.fold<int>(0, (a, e) => a + e.$2);
|
||||||
|
mbPerS = bytes / 1e6;
|
||||||
|
kbPerFrame = jpeg.length / 1024;
|
||||||
|
final delay = DateTime.now().microsecondsSinceEpoch / 1000 - sentAt * 1000;
|
||||||
|
_delays.add(delay);
|
||||||
|
if (_delays.length > 300) _delays.removeAt(0);
|
||||||
|
lagMs = delay - _delays.reduce((a, b) => a < b ? a : b);
|
||||||
|
frame = jpeg;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_running = false;
|
||||||
|
_s?.destroy();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CamViewPage extends StatefulWidget {
|
||||||
|
const CamViewPage({super.key, required this.host});
|
||||||
|
final String host;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CamViewPage> createState() => _CamViewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CamViewPageState extends State<CamViewPage> {
|
||||||
|
late final cam = CamView(widget.host)..start();
|
||||||
|
bool auto = true;
|
||||||
|
double exposure = 100; // 100 µs units: 10 ms
|
||||||
|
double gain = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
cam.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(title: const Text('Camera (test)')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: cam,
|
||||||
|
builder: (context, _) {
|
||||||
|
final f = cam.frame;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: f == null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
cam.status,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Image.memory(
|
||||||
|
f,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(cam.status),
|
||||||
|
Text('${cam.fps.toStringAsFixed(0)} fps'),
|
||||||
|
Text('${cam.kbPerFrame.toStringAsFixed(0)} KB/frame'),
|
||||||
|
Text('${cam.mbPerS.toStringAsFixed(2)} MB/s'),
|
||||||
|
Text('lag +${cam.lagMs.toStringAsFixed(0)} ms'),
|
||||||
|
FilterChip(
|
||||||
|
label: const Text('Auto exposure'),
|
||||||
|
selected: auto,
|
||||||
|
onSelected: (v) {
|
||||||
|
setState(() => auto = v);
|
||||||
|
cam.send(
|
||||||
|
v
|
||||||
|
? 'exposure auto'
|
||||||
|
: 'exposure ${exposure.round()}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'exp ${(exposure / 10).toStringAsFixed(1)} ms',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
min: 1,
|
||||||
|
max: 600,
|
||||||
|
value: exposure,
|
||||||
|
onChanged: auto
|
||||||
|
? null
|
||||||
|
: (v) => setState(() => exposure = v),
|
||||||
|
onChangeEnd: (v) =>
|
||||||
|
cam.send('exposure ${v.round()}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text('gain ${gain.round()}'),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
min: 0,
|
||||||
|
max: 240,
|
||||||
|
value: gain,
|
||||||
|
onChanged: (v) => setState(() => gain = v),
|
||||||
|
onChangeEnd: (v) =>
|
||||||
|
cam.send('gain ${v.round()}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cam.lastReply.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
cam.lastReply,
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'dart:math';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'boundary.dart';
|
import 'boundary.dart';
|
||||||
|
import 'camview.dart';
|
||||||
import 'linktest.dart';
|
import 'linktest.dart';
|
||||||
import 'net.dart';
|
import 'net.dart';
|
||||||
|
|
||||||
@@ -170,6 +171,13 @@ class LinkBar extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.speed),
|
icon: const Icon(Icons.speed),
|
||||||
label: const Text('Link test'),
|
label: const Text('Link test'),
|
||||||
),
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => CamViewPage(host: Uri.parse(b.url).host)),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.videocam),
|
||||||
|
label: const Text('Camera'),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Camera live view for testing (lib/camview.dart). Development tool, not the vision service.
|
||||||
|
|
||||||
|
Streams the USB camera's own MJPEG frames, unmodified, to any client on 7710/tcp:
|
||||||
|
|
||||||
|
server → client, per frame: [u32 length][f64 capture time, unix s][u32 seq][JPEG]
|
||||||
|
per reply: [u32 0][f64 time][u32 text length][UTF-8 text]
|
||||||
|
client → server, lines: "exposure auto" | "exposure <100 µs units>" | "gain <0-240>"
|
||||||
|
→ one reply, "ok <state>" or "err <reason>"
|
||||||
|
|
||||||
|
Latest-wins: each client gets the newest frame when it is ready for one, never a
|
||||||
|
queue of old ones. Sockets are paced (SO_MAX_PACING_RATE, project-yukart decision
|
||||||
|
28) because unpaced bursts stall the USB link to the tablet for seconds.
|
||||||
|
|
||||||
|
Run on the Orin: python3 camview_server.py [--size 1280x720] [--fps 30]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
SO_MAX_PACING_RATE = 47
|
||||||
|
CONTROLS = {"exposure", "gain"}
|
||||||
|
|
||||||
|
|
||||||
|
class Latest:
|
||||||
|
"""The newest frame, and a condition to wait for the next one."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cond = threading.Condition()
|
||||||
|
self.frame = None # (seq, capture_time, jpeg)
|
||||||
|
|
||||||
|
def put(self, seq, t, jpeg):
|
||||||
|
with self.cond:
|
||||||
|
self.frame = (seq, t, jpeg)
|
||||||
|
self.cond.notify_all()
|
||||||
|
|
||||||
|
def after(self, seq, timeout=5.0):
|
||||||
|
with self.cond:
|
||||||
|
self.cond.wait_for(lambda: self.frame and self.frame[0] > seq, timeout)
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
|
||||||
|
def capture(dev, w, h, fps, latest):
|
||||||
|
# multipartmux frames each JPEG with a Content-Length header, so no JPEG parsing is needed.
|
||||||
|
cmd = ["gst-launch-1.0", "-q", "v4l2src", f"device={dev}", "!",
|
||||||
|
f"image/jpeg,width={w},height={h},framerate={fps}/1", "!",
|
||||||
|
"multipartmux", "boundary=yukartframe", "!", "fdsink", "fd=1"]
|
||||||
|
seq = 0
|
||||||
|
while True:
|
||||||
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
out = p.stdout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
length = None
|
||||||
|
while True: # headers up to the blank line
|
||||||
|
line = out.readline()
|
||||||
|
if not line:
|
||||||
|
raise EOFError
|
||||||
|
line = line.strip()
|
||||||
|
if line.lower().startswith(b"content-length:"):
|
||||||
|
length = int(line.split(b":")[1])
|
||||||
|
elif not line and length is not None:
|
||||||
|
break
|
||||||
|
jpeg = out.read(length)
|
||||||
|
if len(jpeg) < length:
|
||||||
|
raise EOFError
|
||||||
|
seq += 1
|
||||||
|
latest.put(seq, time.time(), jpeg)
|
||||||
|
except EOFError:
|
||||||
|
print("capture ended; restarting in 1 s", flush=True)
|
||||||
|
p.kill()
|
||||||
|
p.wait()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def v4l2(dev, *args):
|
||||||
|
r = subprocess.run(["v4l2-ctl", "-d", dev, *args], capture_output=True, text=True)
|
||||||
|
return r.returncode == 0, (r.stdout + r.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def control(dev, line):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2 or parts[0] not in CONTROLS:
|
||||||
|
return "err expected: exposure auto|<n> or gain <n>"
|
||||||
|
name, value = parts
|
||||||
|
if name == "exposure" and value == "auto":
|
||||||
|
ok, msg = v4l2(dev, "-c", "auto_exposure=3")
|
||||||
|
elif name == "exposure" and value.isdigit():
|
||||||
|
ok, msg = v4l2(dev, "-c", "auto_exposure=1", "-c", f"exposure_time_absolute={value}")
|
||||||
|
elif name == "gain" and value.isdigit():
|
||||||
|
ok, msg = v4l2(dev, "-c", f"gain={value}")
|
||||||
|
else:
|
||||||
|
return "err bad value"
|
||||||
|
_, state = v4l2(dev, "-C", "auto_exposure,exposure_time_absolute,gain")
|
||||||
|
return ("ok " if ok else "err ") + (msg or " ".join(state.split()))
|
||||||
|
|
||||||
|
|
||||||
|
def serve_client(conn, peer, dev, latest, pace):
|
||||||
|
conn.setsockopt(socket.SOL_SOCKET, SO_MAX_PACING_RATE, pace)
|
||||||
|
conn.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 256 * 1024) # bound what can queue behind a frame
|
||||||
|
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
alive = threading.Event()
|
||||||
|
alive.set()
|
||||||
|
send = threading.Lock() # frames and replies share the socket; never interleave them
|
||||||
|
|
||||||
|
def reader():
|
||||||
|
buf = b""
|
||||||
|
try:
|
||||||
|
while alive.is_set():
|
||||||
|
b = conn.recv(1024)
|
||||||
|
if not b:
|
||||||
|
break
|
||||||
|
buf += b
|
||||||
|
while b"\n" in buf:
|
||||||
|
line, buf = buf.split(b"\n", 1)
|
||||||
|
reply = control(dev, line.decode(errors="replace").strip())
|
||||||
|
print(f"{peer}: {line.decode(errors='replace').strip()} -> {reply}", flush=True)
|
||||||
|
# Replies go in-band as a frame with length 0 and the text after the header.
|
||||||
|
msg = reply.encode()
|
||||||
|
with send:
|
||||||
|
conn.sendall(struct.pack("!IdI", 0, time.time(), len(msg)) + msg)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
alive.clear()
|
||||||
|
|
||||||
|
threading.Thread(target=reader, daemon=True).start()
|
||||||
|
seq = 0
|
||||||
|
sent = 0
|
||||||
|
try:
|
||||||
|
while alive.is_set():
|
||||||
|
f = latest.after(seq)
|
||||||
|
if not f or f[0] <= seq:
|
||||||
|
continue
|
||||||
|
seq, t, jpeg = f
|
||||||
|
with send:
|
||||||
|
conn.sendall(struct.pack("!IdI", len(jpeg), t, seq) + jpeg)
|
||||||
|
sent += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
alive.clear()
|
||||||
|
print(f"{peer}: gone after {sent} frames", flush=True)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def allowed(ip):
|
||||||
|
a = ipaddress.ip_address(ip)
|
||||||
|
return a.is_loopback or a.is_private or a in ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dev", default="/dev/video0")
|
||||||
|
ap.add_argument("--size", default="1280x720")
|
||||||
|
ap.add_argument("--fps", type=int, default=30)
|
||||||
|
ap.add_argument("--port", type=int, default=7710)
|
||||||
|
ap.add_argument("--pace", type=int, default=4_000_000, help="bytes/s per client")
|
||||||
|
a = ap.parse_args()
|
||||||
|
w, h = map(int, a.size.split("x"))
|
||||||
|
latest = Latest()
|
||||||
|
threading.Thread(target=capture, args=(a.dev, w, h, a.fps, latest), daemon=True).start()
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind(("0.0.0.0", a.port))
|
||||||
|
s.listen(4)
|
||||||
|
print(f"camview: {a.dev} {w}x{h}@{a.fps} MJPEG on tcp {a.port}, paced {a.pace / 1e6} MB/s per client", flush=True)
|
||||||
|
while True:
|
||||||
|
conn, addr = s.accept()
|
||||||
|
if not allowed(addr[0]):
|
||||||
|
conn.close()
|
||||||
|
continue
|
||||||
|
print(f"{addr[0]}: connected", flush=True)
|
||||||
|
threading.Thread(target=serve_client, args=(conn, addr[0], a.dev, latest, a.pace), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user