Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ session.o: session.cpp session.h keydb.h
binlog.o: binlog.cpp binlog.h session.h mavlink.h util.h cleanup.h $(MAVLINK_DIR)/protocol.h
cleanup.o: cleanup.cpp cleanup.h keydb.h
websocket.o: websocket.cpp websocket.h util.h
video.o videoview.o: videomkv.h

video.o: video.cpp video.h videoauth.h videots.h videostream.h videorec.h videoview.h httpreq.h videortsp.h videortmp.h conntdb.h keydb.h util.h
videoauth.o: videoauth.cpp videoauth.h conntdb.h keydb.h
videots.o: videots.cpp videots.h
videostream.o: videostream.cpp videostream.h
video.o: videorec.h

videorec.o: videorec.cpp videorec.h session.h cleanup.h
videoview.o: videoview.cpp videoview.h httpreq.h videostream.h videots.h videoauth.h keydb.h
httpreq.o: httpreq.cpp httpreq.h
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,66 @@ runs one pytest invocation against exactly what you passed.
SupportProxy is licensed under the GNU General Public License version 3 or later.

See `COPYING.txt` for full license terms.

### Lossless raw thermal (FFV1/Matroska)

Updated MT11/SITL-MT11 firmware can publish its third stream to an allocated
video port using `[support_proxy] video3_port` (MAVLink `PROXY_VID3_PORT`).
Enable video and allocate three distinct ports on the proxy entry; no database
migration or additional slot flag is required. The raw stream uses the same
publish password or permitted MAVLink-session admission as ordinary video.
The camera's `RAW_STREAM_FPS` controls transmission rate. This requires both
the camera firmware and SupportProxy Matroska updates.

Publishers use `PUT /vN.mkv?pw=...`, `Content-Type: video/x-matroska`,
`Transfer-Encoding: chunked` and `Expect: 100-continue`. An accepted publisher
receives `100 Continue` before sending the EBML header and Clusters. A wrong
or explicitly empty password fails even when session fallback is enabled.
The bounded parser accepts the APCG live profile: EBML header, unknown-sized
Segment, Info, Tracks and finite independent FFV1 Clusters (maximum 4 MiB).
HTTP chunk boundaries need not align with EBML elements. It is not a generic
upload endpoint for arbitrary Matroska layouts or inter-frame codecs.

Viewers use `http://HOST:PORT/v3.mkv` for the camera's third stream. Existing
viewer-password (`?pw=...` or HTTP Basic), short-lived viewer tokens (`?t=...`),
WebSocket and enabled raw-TCP access policies apply. Late viewers receive the
cached Matroska header followed by the newest complete Cluster and subsequent
publisher bytes. Pixels and BlockAdditional telemetry are never transcoded.
Slow viewers are disconnected independently; a publisher disconnect closes its
viewers, who must reconnect to the next session.

The slot's **record** option saves `.v3.mkv` files. Segments rotate on complete
Cluster boundaries with a repeated header, and participate in the existing
video quota and log-download listing. They do not use the MPEG-TS browser log
player. The live web page identifies active Matroska publishers and offers a
desktop viewer instead of its MPEG-TS player (reload after changing formats).

MAVProxy connected to this proxy discovers the correct URL automatically:

```text
module load camera
camera discover
camera view rawthermal
```

For standalone viewing, install a MAVProxy version with the raw thermal
reader, PyAV >= 18.1, numpy and OpenCV with GUI support, then run:

```sh
python3 scripts/view_raw_thermal.py http://HOST:PORT/v3.mkv
# Or use a MAVProxy checkout:
python3 scripts/view_raw_thermal.py --mavproxy /path/to/MAVProxy http://HOST:PORT/v3.mkv
```

Greyscale is the default. Hover for pixel temperature; Space pauses, C selects
optional palettes, S saves native little-endian uint16 pixels and frame JSON,
and Q/Escape exits. The window readout is separate from the image. A display
palette never changes the saved samples. `--headless --frames 10` validates
reception without a display. ffplay can also display the stream, but lacks the
temperature and capture-metadata handling of the thermal viewers. The existing
browser MPEG-TS player cannot decode this FFV1/16-bit stream.

Tests: `python3 -m pytest tests/test_video_matroska.py` covers authentication,
fragmentation, late joining, restart, slow viewers, malformed lengths and
recording rotation. The camera repository's `sitl/test_support_proxy.py
--raw-thermal --reconnect` additionally checks actual FFV1 pixels and telemetry.
3 changes: 3 additions & 0 deletions cleanup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ static session_kind session_file_kind(const char *name)
&& name[n - 4] >= '1' && name[n - 4] <= '9') {
return SESSION_VIDEO;
}
if (n > 7 && strcmp(name+n-4, ".mkv") == 0 &&
name[n-7] == '.' && name[n-6] == 'v' && name[n-5] >= '1' && name[n-5] <= '9')
return SESSION_VIDEO;
return SESSION_NONE;
}

Expand Down
1 change: 1 addition & 0 deletions conntdb.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
#define CONN_APP_HTTP 3
#define CONN_APP_SRT 4
#define CONN_APP_RTMP 5
#define CONN_APP_MATROSKA 7
#define CONN_APP_RTP 6 // bare RTP over UDP

/*
Expand Down
2 changes: 2 additions & 0 deletions conntdb_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
CONN_APP_SRT = 4
CONN_APP_RTMP = 5
CONN_APP_RTP = 6
CONN_APP_MATROSKA = 7

APP_NAMES = {
CONN_APP_MAVLINK: 'mavlink',
Expand All @@ -83,6 +84,7 @@
CONN_APP_SRT: 'srt',
CONN_APP_RTMP: 'rtmp',
CONN_APP_RTP: 'rtp',
CONN_APP_MATROSKA: 'matroska',
}

# Video rows occupy a conn_index range disjoint from the MAVLink ones so
Expand Down
125 changes: 125 additions & 0 deletions scripts/view_raw_thermal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""View lossless APCG thermal Matroska from SupportProxy or a local camera.

Requires the MAVProxy raw thermal reader, PyAV >= 18.1, numpy and OpenCV.
Space pauses; C cycles greyscale/inferno/turbo; S saves native pixels and JSON.
"""
import argparse
import json
from pathlib import Path
import sys
import threading
import time


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('uri')
parser.add_argument('--mavproxy', type=Path, help='MAVProxy checkout containing the raw thermal reader')
parser.add_argument('--output', type=Path, default=Path('thermal-captures'))
parser.add_argument('--headless', action='store_true', help='validate decoded frames without opening a window')
parser.add_argument('--frames', type=int, default=0, help='exit after this many received frames (0: unlimited)')
args = parser.parse_args()
if args.frames < 0:
parser.error('--frames must be nonnegative')
if args.mavproxy:
sys.path.insert(0, str(args.mavproxy.resolve()))
import numpy as np
from MAVProxy.modules.mavproxy_camera.thermal_stream import ThermalReader
if not args.headless:
import cv2
stop = threading.Event()
lock = threading.Lock()
latest, error, count = None, None, 0

def receive():
nonlocal latest, error, count
while not stop.is_set():
reader = None
try:
reader = ThermalReader(args.uri)
for pixels, metadata in reader.frames():
with lock:
latest = pixels, metadata
error = None
count += 1
if stop.is_set() or (args.frames and count >= args.frames):
return
raise RuntimeError('stream ended')
except Exception as exc:
# Exception URLs can contain the viewer password.
with lock:
error = 'Stream unavailable (%s); reconnecting' % type(exc).__name__
finally:
if reader:
reader.close()
stop.wait(1)

worker = threading.Thread(target=receive, daemon=True)
worker.start()
shown = None
cursor = None
paused, palette = False, 0
window = 'Raw Thermal'
if not args.headless:
cv2.namedWindow(window, cv2.WINDOW_AUTOSIZE)
def mouse(_event, x, y, _flags, _data):
nonlocal cursor
cursor = (x, y) if 0 <= x < 640 and 0 <= y < 512 else None
cv2.setMouseCallback(window, mouse)
try:
while True:
with lock:
frame, status, received = latest, error, count
if not paused and frame is not None:
shown = frame
if args.headless:
if args.frames and received >= args.frames:
print('Decoded %u native 16-bit frames with metadata' % received)
break
time.sleep(.02)
continue
if shown is not None:
pixels, metadata = shown
lo, hi = int(pixels.min()), int(pixels.max())
grey = ((pixels.astype(np.float32)-lo)*(255/max(1, hi-lo))).astype(np.uint8)
if metadata['rotation_deg'] == 180:
grey = grey[::-1, ::-1]
display = cv2.cvtColor(grey, cv2.COLOR_GRAY2BGR) if palette == 0 else cv2.applyColorMap(
grey, (cv2.COLORMAP_INFERNO, cv2.COLORMAP_TURBO)[palette-1])
display = cv2.copyMakeBorder(display, 0, 68, 0, 0, cv2.BORDER_CONSTANT)
text = 'Min %.2f C Max %.2f C%s' % (
metadata['minimum_c'], metadata['maximum_c'], ' PAUSED' if paused else '')
pixel_text = 'Hover for temperature; Space pause, C palette, S save'
if cursor:
x, y = cursor
sx, sy = (639-x, 511-y) if metadata['rotation_deg'] == 180 else (x, y)
temp = int(pixels[sy, sx])*metadata['temperature_scale_k']+metadata['temperature_offset_k']-273.15
pixel_text = 'Pixel (%u,%u): %.3f C' % (x, y, temp)
cv2.putText(display, text, (8, 536), cv2.FONT_HERSHEY_SIMPLEX, .55, (255,255,255), 1)
cv2.putText(display, status or pixel_text, (8, 564), cv2.FONT_HERSHEY_SIMPLEX, .5, (255,255,255), 1)
cv2.imshow(window, display)
key = cv2.waitKey(20) & 255
if key in (27, ord('q')) or cv2.getWindowProperty(window, cv2.WND_PROP_VISIBLE) < 1:
break
if key == ord(' '): paused = not paused
if key == ord('c'): palette = (palette+1) % 3
if key == ord('s') and shown is not None:
pixels, metadata = shown
args.output.mkdir(parents=True, exist_ok=True)
stem = args.output / ('%u_%u' % (metadata['capture_monotonic_us'], metadata['frame_id']))
with stem.with_suffix('.bin').open('xb') as f:
f.write(pixels.astype('<u2', copy=False).tobytes())
with stem.with_suffix('.json').open('x') as f:
json.dump(metadata, f, indent=2)
if args.frames and received >= args.frames: break
finally:
stop.set()
# PyAV containers are owned and closed by the reader thread. Its
# bounded network timeout lets shutdown complete without racing decode.
worker.join(timeout=6)
if not args.headless: cv2.destroyAllWindows()


if __name__ == '__main__':
main()
6 changes: 3 additions & 3 deletions session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ static bool basename_free(const char *dir, const char *candidate)
}
}
for (int slot = 1; slot <= KEY_MAX_VIDEO_PORTS; slot++) {
snprintf(p, sizeof(p), "%s/%s.v%d.ts", dir, candidate, slot);
if (stat(p, &st) == 0) {
return false;
for (const char *ext : {"ts", "mkv"}) {
snprintf(p, sizeof(p), "%s/%s.v%d.%s", dir, candidate, slot, ext);
if (stat(p, &st) == 0) return false;
}
}
return true;
Expand Down
Loading
Loading