增加远程、本地查看摄像头功能

This commit is contained in:
iRankoo
2026-08-15 19:17:12 +08:00
parent b54ad411c0
commit f3a99f9467
11 changed files with 2346 additions and 289 deletions
+103 -2
View File
@@ -23,7 +23,7 @@
export ATHENA_HOST='ws://laolang.duckdns.org:7899'
export API_HOST='http://laolang.duckdns.org:7898'
export MAPBOX_TOKEN='pk.eyJ1Ijoiam5ld2IiLCJhIjoiY2xxNW8zZXprMGw1ZzJwbzZneHd2NHljbSJ9.gV7VPRfbXFetD-1OVF0XZg'
export MAPBOX_TOKEN='<自己注册>'
cd /data/openpilot
exec ./launch_openpilot.sh
@@ -51,7 +51,108 @@ exec ./launch_openpilot.sh
#### 5. 暴露端口和运行docker
sudo docker run -p 7899:7899 -p 7898:7898 -p 7888:7888 -p 1201:1201 -p 5888:5888 opsvr:v1
#### 6.Star me. Thank you.如果觉得很复杂,可以直接看第二步,使用我的服务器。
#### 6. 在 openpilot 中启用 H.264 实时画面
(1) 确保准备好以下文件,并复制到设备的
`/data/openpilot/selfdrive/fp/`
```text
SConscript
main.cc
streamup845.cc
streamup845.h
viewer.html
websocket_server.py
```
其中 `main.cc``h264_streamer` 的程序入口,缺少它将无法生成
`selfdrive/fp/h264_streamer`。当前发布包如果没有该文件,需要从对应的
openpilot/dragonpilot H.264 实现中补齐。
骁龙 845(C3/C3X,构建架构为 `larch64`)使用
`streamup845.cc`。其他 Linux PC/AMD 平台需要同时提供
`streamup.cc``streamup.h`
(2) 在 `/data/openpilot/selfdrive/SConscript` 中增加:
```python
SConscript(['fp/SConscript'])
```
(3) 在 `/data/openpilot/system/manager/process_config.py` 的进程列表中增加:
```python
NativeProcess("h264_streamer", "selfdrive/fp", ["./h264_streamer"], always_run),
PythonProcess("websocket_server", "selfdrive.fp.websocket_server", always_run),
```
(4) 在 `/data/continue.sh` 中配置远端 WebSocket 服务。`ATHENA_HOST`
必须指向服务端 `config.txt``ws_bind` 公网地址:
```bash
export ATHENA_HOST='ws://your-domain.example:7899'
# 可选配置,以下为默认值
export H264_WS_PORT='8089'
export H264_WS_FPS='20'
export H264_WS_BITRATE='2000000'
export H264_REMOTE_MAX_LATENCY='1.5'
```
通常不要设置 `H264_WS_HOST`。它同时用于设备本地
`websocket_server.py` 的监听地址,以及 `h264_streamer` 连接本地服务。
不设置时,Python 服务监听 `0.0.0.0:8089`,编码器连接
`127.0.0.1:8089`。不要将它设置为公网服务器地址,也不要显式设置为
`0.0.0.0`
(5) 重新编译并确认生成可执行文件:
```bash
cd /data/openpilot
scons -j$(nproc) selfdrive/fp/h264_streamer
ls -l selfdrive/fp/h264_streamer
```
如果当前 openpilot 版本不支持指定单个目标,可以直接运行:
```bash
scons -j$(nproc)
```
(6) 服务端需要满足以下条件:
- 使用支持 H.264 实时画面的新版 `opserver`
- `config.txt``athena_host`/`ws_bind` 的公网端口可从设备和浏览器访问。
- 防火墙、Docker 和路由器同时放行 `http_bind``ws_bind`,例如 TCP
`7898` 和 TCP `7899`
- 网页必须连接 `ws_bind` 对应的 WebSocket 端口。如果网页从
`http://domain:7898` 打开,而脚本使用 `location.host`,它会错误连接
`7898`。应通过反向代理把 H.264 WebSocket 请求转发到 `7899`,或者在
网页中明确使用 `ws_bind` 的公网地址。
- HTTPS 页面必须使用 `wss://`,不能连接明文 `ws://`;推荐使用 Nginx、
Caddy 等为 HTTP 和 WebSocket 提供同一 HTTPS 域名。
- 浏览器需要支持 WebCodecs H.264,建议使用新版 Chrome 或 Edge。
(7) 重启后检查进程和端口:
```bash
sudo reboot
# 重连 SSH 后执行
pgrep -af 'h264_streamer|websocket_server'
ss -lntp | grep 8089
```
局域网测试:
```text
http://<设备IP>:8089/viewer.html
```
广域网测试:打开服务端网页,输入正确的 Dongle ID,再选择 Road Cam 或
Wide Cam。必须先确保设备的 `DongleId` 不为空,并且与网页输入值一致。
#### 7.Star me. Thank you.如果觉得很复杂,可以直接看第二步,使用我的服务器。
# openpilot-server for english
+12
View File
@@ -0,0 +1,12 @@
Import('env', 'arch', 'common', 'messaging', 'visionipc')
if arch != "Darwin":
streamup_src = 'streamup845.cc' if arch == "larch64" else 'streamup.cc'
libs = [visionipc, messaging, common,
'avcodec', 'avutil',
'OpenCL',
'pthread']
if arch == "larch64":
libs += ['yuv']
env.Library('fp', [streamup_src], LIBS=libs)
env.Program('h264_streamer', ['main.cc', streamup_src], LIBS=libs)
+90
View File
@@ -0,0 +1,90 @@
#include <chrono>
#include <cstdio>
#include <thread>
#include <vector>
#include "common/util.h"
#include "msgq/visionipc/visionipc_client.h"
#ifdef QCOM2
#include "selfdrive/fp/streamup845.h"
#else
#include "selfdrive/fp/streamup.h"
#endif
namespace {
constexpr int DEFAULT_FPS = 20;
constexpr int DEFAULT_BITRATE = 2'000'000;
struct StreamConfig {
VisionStreamType type;
const char *camera;
const char *thread_name;
};
void stream_camera(const StreamConfig config, ExitHandler *do_exit) {
util::set_thread_name(config.thread_name);
while (!*do_exit) {
VisionIpcClient client("camerad", config.type, true);
if (!client.connect(false)) {
util::sleep_for(200);
continue;
}
const VisionBuf &info = client.buffers[0];
if (info.width == 0 || info.height == 0) {
fprintf(stderr, "%s invalid VisionBuf size\n", config.camera);
util::sleep_for(1000);
continue;
}
fprintf(stderr, "%s H.264 streamer connected: %zux%zu\n",
config.camera, info.width, info.height);
AmdH264VaapiEncoder encoder(
static_cast<int>(info.width),
static_cast<int>(info.height),
config.camera,
DEFAULT_FPS,
DEFAULT_BITRATE,
"");
while (!*do_exit && client.is_connected()) {
VisionIpcBufExtra extra = {};
VisionBuf *buf = client.recv(&extra, 1000);
if (!buf) {
continue;
}
if (buf->get_frame_id() != extra.frame_id) {
continue;
}
if (!encoder.encode_frame(buf)) {
util::sleep_for(50);
}
}
fprintf(stderr, "%s VisionIPC disconnected, reconnecting\n", config.camera);
}
}
} // namespace
int main() {
ExitHandler do_exit;
const std::vector<StreamConfig> streams = {
{VISION_STREAM_ROAD, "roadCameraState", "h264_road"},
{VISION_STREAM_WIDE_ROAD, "wideRoadCameraState", "h264_wide"},
};
std::vector<std::thread> threads;
threads.reserve(streams.size());
for (const StreamConfig &config : streams) {
threads.emplace_back(stream_camera, config, &do_exit);
}
for (std::thread &thread : threads) {
thread.join();
}
return 0;
}
+458
View File
@@ -0,0 +1,458 @@
#include "selfdrive/fp/streamup845.h"
#include <algorithm>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
#include "common/swaglog.h"
#include "third_party/libyuv/include/libyuv.h"
extern "C" {
#include <libavutil/opt.h>
}
namespace {
constexpr auto RECONNECT_DELAY = std::chrono::seconds(2);
bool write_all(int fd, const uint8_t *data, size_t size) {
while (size > 0) {
const ssize_t written = send(fd, data, size, MSG_NOSIGNAL);
if (written < 0) {
if (errno == EINTR) {
continue;
}
return false;
}
data += written;
size -= static_cast<size_t>(written);
}
return true;
}
} // namespace
AmdH264VaapiEncoder::AmdH264VaapiEncoder(int width, int height, const std::string &camera,
int fps, int bitrate, const std::string &device_path,
const std::string &host, int port)
: width_(width),
height_(height),
fps_(fps),
bitrate_(bitrate),
camera_(camera),
host_(host),
port_(port),
next_reconnect_(std::chrono::steady_clock::now()) {
(void)device_path;
if (const char *host_env = getenv("H264_WS_HOST"); host_env && host_env[0] != '\0') {
host_ = host_env;
}
if (const char *port_env = getenv("H264_WS_PORT"); port_env && port_env[0] != '\0') {
port_ = std::max(1, atoi(port_env));
}
if (const char *fps_env = getenv("H264_WS_FPS"); fps_env && fps_env[0] != '\0') {
fps_ = std::max(1, atoi(fps_env));
}
if (const char *bitrate_env = getenv("H264_WS_BITRATE"); bitrate_env && bitrate_env[0] != '\0') {
bitrate_ = std::max(1, atoi(bitrate_env));
}
gop_size_ = fps_ > 0 ? fps_ * 2 : 40;
sw_frame_ = av_frame_alloc();
convert_buf_.resize(static_cast<size_t>(width_) * height_ * 3 / 2);
}
AmdH264VaapiEncoder::~AmdH264VaapiEncoder() {
close_socket();
close();
av_frame_free(&sw_frame_);
}
bool AmdH264VaapiEncoder::open() {
if (opened_) {
return true;
}
codec_ = avcodec_find_encoder(AV_CODEC_ID_H264);
if (!codec_) {
LOGE("FFmpeg H.264 encoder not found");
return false;
}
codec_ctx_ = avcodec_alloc_context3(codec_);
if (!codec_ctx_) {
LOGE("avcodec_alloc_context3 failed");
return false;
}
codec_ctx_->width = width_;
codec_ctx_->height = height_;
codec_ctx_->time_base = AVRational{1, fps_};
codec_ctx_->framerate = AVRational{fps_, 1};
codec_ctx_->pix_fmt = AV_PIX_FMT_YUV420P;
codec_ctx_->bit_rate = bitrate_;
codec_ctx_->gop_size = gop_size_;
codec_ctx_->max_b_frames = 0;
codec_ctx_->color_range = AVCOL_RANGE_MPEG;
codec_ctx_->colorspace = AVCOL_SPC_BT709;
codec_ctx_->color_primaries = AVCOL_PRI_BT709;
codec_ctx_->color_trc = AVCOL_TRC_BT709;
AVDictionary *opts = nullptr;
av_dict_set(&opts, "preset", "ultrafast", 0);
av_dict_set(&opts, "tune", "zerolatency", 0);
av_dict_set(&opts, "x264-params", "annexb=1:repeat-headers=1", 0);
const int ret = avcodec_open2(codec_ctx_, codec_, &opts);
av_dict_free(&opts);
if (ret < 0) {
LOGE("avcodec_open2(H.264) failed: %d", ret);
release();
return false;
}
opened_ = true;
frame_idx_ = 0;
return true;
}
void AmdH264VaapiEncoder::release() {
if (codec_ctx_) {
avcodec_free_context(&codec_ctx_);
}
opened_ = false;
}
void AmdH264VaapiEncoder::close() {
if (!opened_ && !codec_ctx_) {
return;
}
if (codec_ctx_) {
avcodec_send_frame(codec_ctx_, nullptr);
drain_packets(nullptr);
}
release();
}
bool AmdH264VaapiEncoder::drain_packets(std::vector<uint8_t> *out) {
AVPacket *pkt = av_packet_alloc();
if (!pkt) {
LOGE("av_packet_alloc failed");
return false;
}
while (true) {
const int ret = avcodec_receive_packet(codec_ctx_, pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
av_packet_free(&pkt);
return true;
}
if (ret < 0) {
LOGE("avcodec_receive_packet failed: %d", ret);
av_packet_free(&pkt);
return false;
}
if (out) {
out->insert(out->end(), pkt->data, pkt->data + pkt->size);
}
av_packet_unref(pkt);
}
}
bool AmdH264VaapiEncoder::ensure_connected() {
if (socket_fd_ >= 0) {
return true;
}
const auto now = std::chrono::steady_clock::now();
if (now < next_reconnect_) {
return false;
}
next_reconnect_ = now + RECONNECT_DELAY;
addrinfo hints = {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo *result = nullptr;
const std::string port = std::to_string(port_);
if (getaddrinfo(host_.c_str(), port.c_str(), &hints, &result) != 0) {
return false;
}
for (addrinfo *rp = result; rp != nullptr; rp = rp->ai_next) {
const int fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (fd < 0) {
continue;
}
if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) {
socket_fd_ = fd;
break;
}
::close(fd);
}
freeaddrinfo(result);
if (socket_fd_ < 0) {
return false;
}
if (!send_handshake() || !send_camera_register()) {
close_socket();
return false;
}
has_viewer_ = false;
restart_encoder_ = true;
return true;
}
bool AmdH264VaapiEncoder::send_handshake() {
const std::string path = "/ingest?camera=" + camera_;
const std::string request =
"GET " + path + " HTTP/1.1\r\n"
"Host: " + host_ + ":" + std::to_string(port_) + "\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: ZHJhZ29ucGlsb3QtaDI2NA==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n";
if (!write_all(socket_fd_, reinterpret_cast<const uint8_t *>(request.data()), request.size())) {
return false;
}
std::string response;
char buffer[512];
while (response.find("\r\n\r\n") == std::string::npos && response.size() < 4096) {
const ssize_t size = recv(socket_fd_, buffer, sizeof(buffer), 0);
if (size <= 0) {
return false;
}
response.append(buffer, static_cast<size_t>(size));
}
return response.find(" 101 ") != std::string::npos;
}
bool AmdH264VaapiEncoder::send_camera_register() {
return send_packet(0, reinterpret_cast<const uint8_t *>(camera_.data()), camera_.size());
}
bool AmdH264VaapiEncoder::send_packet(uint8_t cmd, const uint8_t *data, size_t size) {
if (size > 0xffffff) {
LOGE("H.264 websocket payload too large: %zu", size);
return false;
}
std::vector<uint8_t> packet;
packet.reserve(size + 4);
packet.push_back(cmd);
packet.push_back(static_cast<uint8_t>((size >> 16) & 0xff));
packet.push_back(static_cast<uint8_t>((size >> 8) & 0xff));
packet.push_back(static_cast<uint8_t>(size & 0xff));
packet.insert(packet.end(), data, data + size);
return send_ws_binary(packet.data(), packet.size());
}
bool AmdH264VaapiEncoder::send_ws_binary(const uint8_t *data, size_t size) {
std::vector<uint8_t> header = {0x82};
if (size <= 125) {
header.push_back(static_cast<uint8_t>(size));
} else if (size <= 0xffff) {
header.push_back(126);
header.push_back(static_cast<uint8_t>((size >> 8) & 0xff));
header.push_back(static_cast<uint8_t>(size & 0xff));
} else {
header.push_back(127);
for (int shift = 56; shift >= 0; shift -= 8) {
header.push_back(static_cast<uint8_t>((static_cast<uint64_t>(size) >> shift) & 0xff));
}
}
return write_all(socket_fd_, header.data(), header.size()) && write_all(socket_fd_, data, size);
}
void AmdH264VaapiEncoder::read_control_messages() {
if (socket_fd_ < 0) {
return;
}
while (true) {
uint8_t data[4096];
const ssize_t size = recv(socket_fd_, data, sizeof(data), MSG_DONTWAIT);
if (size == 0) {
close_socket();
return;
}
if (size < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
break;
}
close_socket();
return;
}
rx_buffer_.insert(rx_buffer_.end(), data, data + size);
}
size_t offset = 0;
while (rx_buffer_.size() - offset >= 2) {
const uint8_t *header = rx_buffer_.data() + offset;
const uint8_t opcode = header[0] & 0x0f;
const bool masked = (header[1] & 0x80) != 0;
uint64_t payload_size = header[1] & 0x7f;
size_t header_size = 2;
if (payload_size == 126) {
if (rx_buffer_.size() - offset < 4) break;
payload_size = (static_cast<uint64_t>(header[2]) << 8) | header[3];
header_size = 4;
} else if (payload_size == 127) {
if (rx_buffer_.size() - offset < 10) break;
payload_size = 0;
for (size_t index = 2; index < 10; ++index) {
payload_size = (payload_size << 8) | header[index];
}
header_size = 10;
}
if (payload_size > 1024 * 1024) {
close_socket();
return;
}
const size_t mask_size = masked ? 4 : 0;
const uint64_t frame_size = header_size + mask_size + payload_size;
if (frame_size > rx_buffer_.size() - offset) break;
const uint8_t *mask = masked ? header + header_size : nullptr;
const uint8_t *payload_data = header + header_size + mask_size;
std::vector<uint8_t> payload(payload_data, payload_data + payload_size);
if (masked) {
for (size_t index = 0; index < payload.size(); ++index) {
payload[index] ^= mask[index % 4];
}
}
offset += static_cast<size_t>(frame_size);
if (opcode == 0x8) {
close_socket();
return;
}
if ((opcode == 0x1 || opcode == 0x2) && payload.size() >= 5 && payload[0] == 2) {
const bool has_viewer = payload[4] == '1';
if (has_viewer && !has_viewer_) {
restart_encoder_ = true;
}
has_viewer_ = has_viewer;
} else if ((opcode == 0x1 || opcode == 0x2) && payload.size() >= 4 && payload[0] == 3) {
restart_encoder_ = true;
}
}
if (offset > 0) {
rx_buffer_.erase(rx_buffer_.begin(), rx_buffer_.begin() + offset);
}
}
void AmdH264VaapiEncoder::close_socket() {
if (socket_fd_ >= 0) {
::close(socket_fd_);
socket_fd_ = -1;
}
has_viewer_ = false;
restart_encoder_ = true;
rx_buffer_.clear();
}
bool AmdH264VaapiEncoder::fps_limited() const {
if (fps_ <= 0 || last_sent_.time_since_epoch().count() == 0) {
return false;
}
const auto interval = std::chrono::microseconds(1000000 / fps_);
return std::chrono::steady_clock::now() - last_sent_ < interval;
}
bool AmdH264VaapiEncoder::encode_frame(const VisionBuf *buf) {
if (!buf) {
return false;
}
if (!ensure_connected()) {
return true;
}
read_control_messages();
if (!has_viewer_ || fps_limited()) {
return true;
}
bool force_keyframe = false;
if (restart_encoder_) {
close();
force_keyframe = true;
restart_encoder_ = false;
}
if (!opened_ && !open()) {
restart_encoder_ = true;
return false;
}
if (buf->width != static_cast<size_t>(width_) || buf->height != static_cast<size_t>(height_)) {
LOGE("input size mismatch: got %zux%zu expect %dx%d", buf->width, buf->height, width_, height_);
return false;
}
uint8_t *y = convert_buf_.data();
uint8_t *u = y + static_cast<size_t>(width_) * height_;
uint8_t *v = u + static_cast<size_t>(width_ / 2) * (height_ / 2);
const int convert_ret = libyuv::NV12ToI420(
buf->y, static_cast<int>(buf->stride),
buf->uv, static_cast<int>(buf->stride),
y, width_,
u, width_ / 2,
v, width_ / 2,
width_, height_);
if (convert_ret != 0) {
LOGE("NV12ToI420 failed: %d", convert_ret);
return false;
}
av_frame_unref(sw_frame_);
sw_frame_->format = AV_PIX_FMT_YUV420P;
sw_frame_->width = width_;
sw_frame_->height = height_;
sw_frame_->data[0] = y;
sw_frame_->data[1] = u;
sw_frame_->data[2] = v;
sw_frame_->linesize[0] = width_;
sw_frame_->linesize[1] = width_ / 2;
sw_frame_->linesize[2] = width_ / 2;
sw_frame_->pts = frame_idx_;
sw_frame_->pict_type =
(force_keyframe || (gop_size_ > 0 && frame_idx_ % gop_size_ == 0))
? AV_PICTURE_TYPE_I
: AV_PICTURE_TYPE_NONE;
const int ret = avcodec_send_frame(codec_ctx_, sw_frame_);
if (ret < 0) {
LOGE("avcodec_send_frame failed: %d", ret);
return false;
}
std::vector<uint8_t> encoded;
if (!drain_packets(&encoded)) {
return false;
}
++frame_idx_;
if (encoded.empty()) {
return true;
}
if (!send_packet(1, encoded.data(), encoded.size())) {
close_socket();
return false;
}
last_sent_ = std::chrono::steady_clock::now();
return true;
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
// Snapdragon 845 H.264 encoding through FFmpeg.
// Input NV12 frames are converted to I420 with libyuv before encoding.
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
#include "msgq/visionipc/visionbuf.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/frame.h>
}
class AmdH264VaapiEncoder {
public:
AmdH264VaapiEncoder(int width, int height, const std::string &camera,
int fps = 20,
int bitrate = 5'000'000,
const std::string &device_path = "",
const std::string &host = "127.0.0.1",
int port = 8089);
~AmdH264VaapiEncoder();
bool open();
void close();
bool encode_frame(const VisionBuf *buf);
bool is_open() const { return opened_; }
private:
bool drain_packets(std::vector<uint8_t> *out);
bool ensure_connected();
bool send_handshake();
bool send_camera_register();
bool send_packet(uint8_t cmd, const uint8_t *data, size_t size);
bool send_ws_binary(const uint8_t *data, size_t size);
void read_control_messages();
void close_socket();
bool fps_limited() const;
void release();
int width_ = 0;
int height_ = 0;
int fps_ = 0;
int bitrate_ = 0;
std::string camera_;
std::string host_;
int port_ = 8089;
AVCodecContext *codec_ctx_ = nullptr;
AVFrame *sw_frame_ = nullptr;
const AVCodec *codec_ = nullptr;
std::vector<uint8_t> convert_buf_;
bool opened_ = false;
int socket_fd_ = -1;
bool has_viewer_ = false;
bool restart_encoder_ = true;
int64_t frame_idx_ = 0;
int gop_size_ = 0;
std::vector<uint8_t> rx_buffer_;
std::chrono::steady_clock::time_point last_sent_;
std::chrono::steady_clock::time_point next_reconnect_;
};
+358
View File
@@ -0,0 +1,358 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenPilot H.264 Preview</title>
<style>
:root {
--bg: #0b1117;
--panel: #121c24;
--text: #edf7f6;
--muted: #8aa0a8;
--accent: #38d39f;
--warn: #ffb86b;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
color: var(--text);
font-family: "Aptos Display", "Segoe UI", sans-serif;
background:
radial-gradient(circle at 18% 18%, rgba(56, 211, 159, 0.18), transparent 28rem),
linear-gradient(145deg, #071017 0%, #101820 52%, #172016 100%);
display: grid;
place-items: center;
padding: 24px;
}
main {
width: min(1100px, 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
background: rgba(18, 28, 36, 0.82);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
h1 {
margin: 0;
font-size: clamp(22px, 4vw, 38px);
letter-spacing: -0.04em;
}
.status {
min-width: 116px;
padding: 8px 12px;
border-radius: 999px;
color: #08120f;
background: var(--warn);
text-align: center;
font-weight: 700;
}
.status.live {
background: var(--accent);
}
.camera-switcher {
display: flex;
gap: 10px;
padding: 14px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
button {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 10px 16px;
color: var(--text);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
font: inherit;
font-weight: 700;
}
button.active {
color: #07120f;
background: var(--accent);
border-color: transparent;
}
.stage {
position: relative;
background: #020607;
aspect-ratio: 16 / 9;
}
canvas {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
text-align: center;
padding: 24px;
}
footer {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px 22px;
color: var(--muted);
flex-wrap: wrap;
}
@media (max-width: 640px) {
body {
padding: 10px;
}
header {
align-items: flex-start;
flex-direction: column;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>H.264 Camera Preview</h1>
<div id="status" class="status">连接中</div>
</header>
<nav class="camera-switcher" aria-label="选择摄像头">
<button class="active" data-camera="roadCameraState">Road Camera</button>
<button data-camera="wideRoadCameraState">Wide Road Camera</button>
</nav>
<section class="stage">
<canvas id="frame"></canvas>
<div id="empty" class="empty">等待摄像头推送画面...</div>
</section>
<footer>
<span>端口 8089 / WebSocket: <strong>/stream?camera=...</strong></span>
<span id="meta">0 frames</span>
</footer>
</main>
<script>
const canvas = document.getElementById("frame");
const context = canvas.getContext("2d");
const empty = document.getElementById("empty");
const statusEl = document.getElementById("status");
const meta = document.getElementById("meta");
const cameraButtons = [...document.querySelectorAll("[data-camera]")];
let currentCamera = "roadCameraState";
let frameCount = 0;
let chunkCount = 0;
let decoder = null;
let decoderConfigured = false;
let timestamp = 0;
let ws = null;
function findNalUnits(data) {
const units = [];
let start = -1;
let index = 0;
while (index + 3 < data.length) {
let prefixLength = 0;
if (data[index] === 0 && data[index + 1] === 0 && data[index + 2] === 1) {
prefixLength = 3;
} else if (index + 4 < data.length &&
data[index] === 0 && data[index + 1] === 0 &&
data[index + 2] === 0 && data[index + 3] === 1) {
prefixLength = 4;
}
if (!prefixLength) {
index += 1;
continue;
}
if (start >= 0) {
units.push(data.subarray(start, index));
}
start = index + prefixLength;
index = start;
}
if (start >= 0 && start < data.length) {
units.push(data.subarray(start));
}
return units;
}
function inspectH264(data) {
let key = false;
let codec = null;
for (const nal of findNalUnits(data)) {
if (!nal.length) continue;
const type = nal[0] & 0x1f;
key ||= type === 5;
if (type === 7 && nal.length >= 4) {
codec = `avc1.${[nal[1], nal[2], nal[3]]
.map((value) => value.toString(16).padStart(2, "0"))
.join("").toUpperCase()}`;
}
}
return { key, codec };
}
function closeDecoder() {
if (decoder) {
try {
decoder.close();
} catch (_) {
}
}
decoder = null;
decoderConfigured = false;
timestamp = 0;
}
function createDecoder() {
if (!("VideoDecoder" in window)) {
statusEl.textContent = "浏览器不支持 WebCodecs";
empty.textContent = "请使用支持 WebCodecs H.264 的 Chrome/Edge 浏览器";
return false;
}
decoder = new VideoDecoder({
output: (videoFrame) => {
if (canvas.width !== videoFrame.displayWidth || canvas.height !== videoFrame.displayHeight) {
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
}
context.drawImage(videoFrame, 0, 0, canvas.width, canvas.height);
videoFrame.close();
empty.style.display = "none";
frameCount += 1;
meta.textContent = `${frameCount} frames / ${currentCamera}`;
},
error: (error) => {
console.error("H.264 decoder error", error);
statusEl.textContent = "解码错误";
statusEl.classList.remove("live");
closeDecoder();
},
});
return true;
}
function resetFrameState() {
closeDecoder();
context.clearRect(0, 0, canvas.width, canvas.height);
empty.style.display = "grid";
empty.textContent = "等待 H.264 关键帧...";
frameCount = 0;
chunkCount = 0;
meta.textContent = `0 frames / ${currentCamera}`;
}
function connect() {
const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:";
const camera = encodeURIComponent(currentCamera);
ws = new WebSocket(`${wsProtocol}//${location.host}/stream?camera=${camera}`);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
statusEl.textContent = "Live";
statusEl.classList.add("live");
};
ws.onmessage = (event) => {
const data = new Uint8Array(event.data);
const info = inspectH264(data);
if (!decoderConfigured) {
if (!info.key || !info.codec) {
return;
}
if (!decoder && !createDecoder()) {
return;
}
try {
decoder.configure({
codec: info.codec,
optimizeForLatency: true,
hardwareAcceleration: "prefer-hardware",
});
decoderConfigured = true;
} catch (error) {
console.error("VideoDecoder configure failed", error);
statusEl.textContent = "不支持该 H.264";
closeDecoder();
return;
}
}
try {
decoder.decode(new EncodedVideoChunk({
type: info.key ? "key" : "delta",
timestamp,
data,
}));
timestamp += 50_000;
chunkCount += 1;
} catch (error) {
console.error("VideoDecoder decode failed", error);
closeDecoder();
}
};
ws.onclose = () => {
statusEl.textContent = "重连中";
statusEl.classList.remove("live");
setTimeout(connect, 1200);
};
ws.onerror = () => {
ws.close();
};
}
window.addEventListener("beforeunload", closeDecoder);
cameraButtons.forEach((button) => {
button.addEventListener("click", () => {
const nextCamera = button.dataset.camera;
if (nextCamera === currentCamera) {
return;
}
currentCamera = nextCamera;
cameraButtons.forEach((item) => item.classList.toggle("active", item === button));
if (ws) {
ws.onclose = null;
ws.close();
}
statusEl.textContent = "连接中";
statusEl.classList.remove("live");
resetFrameState();
connect();
});
});
resetFrameState();
connect();
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
import asyncio
import base64
from collections import deque
import hashlib
import os
import struct
import time
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from openpilot.common.params import Params
HOST = os.getenv("H264_WS_HOST", "0.0.0.0")
PORT = int(os.getenv("H264_WS_PORT", "8089"))
PUSH_FPS = float(os.getenv("H264_WS_FPS", "20"))
ATHENA_HOST = os.getenv("ATHENA_HOST", "")
REMOTE_MAX_LATENCY = float(os.getenv("H264_REMOTE_MAX_LATENCY", "1.5"))
HTML_PATH = Path(__file__).with_name("viewer.html")
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def get_dongle_id():
return os.getenv("DONGLE_ID") or Params().get("DongleId") or ""
class MjpegWebsocketServer:
def __init__(self, push_fps):
self.push_fps = push_fps
self.viewers = {}
self.ingests = {}
self.remote_viewers = {}
self.remote_clients = {}
self.dongle_id = get_dongle_id()
async def handle_client(self, reader, writer):
try:
request = await self.read_http_request(reader)
if not request:
return
method, path, headers = request
if headers.get("upgrade", "").lower() == "websocket":
await self.handle_websocket(path, headers, reader, writer)
else:
await self.handle_http(method, path, writer)
finally:
writer.close()
await writer.wait_closed()
async def read_http_request(self, reader):
data = await reader.readuntil(b"\r\n\r\n")
lines = data.decode("iso-8859-1").split("\r\n")
parts = lines[0].split()
if len(parts) < 2:
return None
headers = {}
for line in lines[1:]:
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return parts[0], parts[1], headers
async def handle_http(self, method, path, writer):
route = urlsplit(path).path
if method != "GET" or route not in ("/", "/viewer.html"):
await self.send_http(writer, "404 Not Found", b"not found", "text/plain")
return
body = HTML_PATH.read_bytes()
await self.send_http(writer, "200 OK", body, "text/html; charset=utf-8")
async def send_http(self, writer, status, body, content_type):
writer.write(
f"HTTP/1.1 {status}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n".encode("ascii") + body
)
await writer.drain()
async def handle_websocket(self, path, headers, reader, writer):
key = headers.get("sec-websocket-key")
url = urlsplit(path)
route = url.path
camera = parse_qs(url.query).get("camera", ["roadCameraState"])[0]
if not key or route not in ("/stream", "/ingest"):
writer.write(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n")
await writer.drain()
return
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode("ascii")).digest()).decode("ascii")
writer.write(
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {accept}\r\n\r\n".encode("ascii")
)
await writer.drain()
if route == "/stream":
await self.viewer_loop(camera, reader, writer)
else:
await self.ingest_loop(camera, reader, writer)
async def viewer_loop(self, camera, reader, writer):
self.viewers.setdefault(camera, set()).add(writer)
await self.notify_ingests(camera)
await self.request_keyframe(camera)
try:
while True:
opcode, _ = await self.read_ws_frame(reader)
if opcode == 0x8:
break
finally:
self.viewers.get(camera, set()).discard(writer)
await self.notify_ingests(camera)
async def ingest_loop(self, camera, reader, writer):
self.ingests.setdefault(camera, set()).add(writer)
await self.notify_ingests(camera)
try:
while True:
opcode, payload = await self.read_ws_frame(reader)
if opcode == 0x8:
break
if opcode == 0x2:
cmd, body = self.parse_ingest_packet(payload)
if cmd == 0:
old_camera = camera
camera = body.decode("utf-8", errors="ignore") or camera
if old_camera != camera:
self.ingests.get(old_camera, set()).discard(writer)
await self.notify_ingests(old_camera)
self.ingests.setdefault(camera, set()).add(writer)
await self.notify_ingests(camera)
elif cmd == 1:
await self.broadcast_frame(camera, body)
finally:
self.ingests.get(camera, set()).discard(writer)
async def notify_ingests(self, camera):
has_viewer = bool(self.viewers.get(camera)) or self.remote_viewers.get(camera, False)
message = self.make_ingest_packet(2, b"1" if has_viewer else b"0")
stale = []
for writer in self.ingests.get(camera, set()):
try:
await self.write_ws_frame(writer, message, opcode=0x2)
except (ConnectionError, OSError):
stale.append(writer)
for writer in stale:
self.ingests.get(camera, set()).discard(writer)
async def request_keyframe(self, camera):
message = self.make_ingest_packet(3, b"")
stale = []
for writer in self.ingests.get(camera, set()):
try:
await self.write_ws_frame(writer, message, opcode=0x2)
except (ConnectionError, OSError):
stale.append(writer)
for writer in stale:
self.ingests.get(camera, set()).discard(writer)
def parse_ingest_packet(self, payload):
if len(payload) < 4:
return None, b""
cmd = payload[0]
length = (payload[1] << 16) | (payload[2] << 8) | payload[3]
if 4 + length > len(payload):
return None, b""
return cmd, payload[4:4 + length]
def make_ingest_packet(self, cmd, payload):
if len(payload) > 0xffffff:
raise ValueError("payload too large")
return bytes((cmd, (len(payload) >> 16) & 0xff, (len(payload) >> 8) & 0xff, len(payload) & 0xff)) + payload
@staticmethod
def is_h264_keyframe(payload):
index = 0
while index + 4 <= len(payload):
if payload[index:index + 4] == b"\x00\x00\x00\x01":
nal_start = index + 4
index = nal_start
elif payload[index:index + 3] == b"\x00\x00\x01":
nal_start = index + 3
index = nal_start
else:
index += 1
continue
if nal_start < len(payload) and payload[nal_start] & 0x1f == 5:
return True
return False
async def broadcast_frame(self, camera, payload):
viewers = self.viewers.get(camera, set())
remote_client = await self.ensure_remote_client(camera)
if not viewers and not self.remote_viewers.get(camera, False):
return
stale = []
for writer in viewers:
try:
await self.write_ws_frame(writer, payload, opcode=0x2)
except (ConnectionError, OSError):
stale.append(writer)
for writer in stale:
viewers.discard(writer)
if stale:
await self.notify_ingests(camera)
if remote_client and self.remote_viewers.get(camera, False):
await remote_client.send_h264(payload)
async def read_ws_frame(self, reader):
header = await reader.readexactly(2)
opcode = header[0] & 0x0f
masked = (header[1] & 0x80) != 0
length = header[1] & 0x7f
if length == 126:
length = struct.unpack("!H", await reader.readexactly(2))[0]
elif length == 127:
length = struct.unpack("!Q", await reader.readexactly(8))[0]
mask = await reader.readexactly(4) if masked else b""
payload = await reader.readexactly(length) if length else b""
if masked:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
return opcode, payload
async def write_ws_frame(self, writer, payload, opcode=0x2):
header = bytearray([0x80 | opcode])
length = len(payload)
if length <= 125:
header.append(length)
elif length <= 0xffff:
header.extend((126, *struct.pack("!H", length)))
else:
header.extend((127, *struct.pack("!Q", length)))
writer.write(bytes(header) + payload)
await writer.drain()
async def ensure_remote_client(self, camera):
if not ATHENA_HOST:
return None
client = self.remote_clients.get(camera)
if client:
return client
client = RemoteH264Client(ATHENA_HOST, self.dongle_id, camera, self)
self.remote_clients[camera] = client
asyncio.create_task(client.run())
return client
async def start_remote_clients(self):
if not ATHENA_HOST:
return
for camera in ("roadCameraState", "wideRoadCameraState"):
await self.ensure_remote_client(camera)
class RemoteH264Client:
def __init__(self, athena_host, dongle_id, camera, server):
self.athena_host = athena_host
self.dongle_id = dongle_id
self.camera = camera
self.server = server
self.reader = None
self.writer = None
self.connected = False
self.write_lock = asyncio.Lock()
self.pending_frames = deque()
self.pending_event = asyncio.Event()
self.waiting_for_keyframe = True
async def run(self):
while True:
try:
await self.connect()
sender_task = asyncio.create_task(self.send_loop())
reader_task = asyncio.create_task(self.read_loop())
try:
done, pending = await asyncio.wait(
(reader_task, sender_task),
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
for task in done:
task.result()
finally:
sender_task.cancel()
reader_task.cancel()
except (ConnectionError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError):
pass
finally:
self.connected = False
self.waiting_for_keyframe = True
self.clear_pending_frames()
self.server.remote_viewers[self.camera] = False
await self.server.notify_ingests(self.camera)
if self.writer:
self.writer.close()
await self.writer.wait_closed()
await asyncio.sleep(2)
async def connect(self):
url = self.normalize_url()
parsed = urlsplit(url)
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
ssl_enabled = parsed.scheme == "wss"
self.reader, self.writer = await asyncio.open_connection(parsed.hostname, port, ssl=ssl_enabled)
base_path = (parsed.path or "").rstrip("/")
path = f"{base_path}/h264/ingest?dongle_id={self.dongle_id}&camera={self.camera}"
key = base64.b64encode(os.urandom(16)).decode("ascii")
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {parsed.netloc}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
)
self.writer.write(request.encode("ascii"))
await self.writer.drain()
response = await self.reader.readuntil(b"\r\n\r\n")
if b" 101 " not in response:
raise ConnectionError("remote websocket upgrade failed")
self.connected = True
await self.send_packet(0, self.camera.encode("utf-8"))
if self.server.remote_viewers.get(self.camera, False):
await self.server.request_keyframe(self.camera)
def normalize_url(self):
if self.athena_host.startswith(("ws://", "wss://", "http://", "https://")):
return self.athena_host.replace("http://", "ws://", 1).replace("https://", "wss://", 1).rstrip("/")
return "ws://" + self.athena_host.rstrip("/")
async def read_loop(self):
while True:
opcode, payload = await self.server.read_ws_frame(self.reader)
if opcode == 0x8:
raise ConnectionError("remote closed")
if opcode == 0x2:
cmd, body = self.server.parse_ingest_packet(payload)
if cmd == 2 and body:
had_viewer = self.server.remote_viewers.get(self.camera, False)
self.server.remote_viewers[self.camera] = body[:1] == b"1"
await self.server.notify_ingests(self.camera)
if not had_viewer and self.server.remote_viewers[self.camera]:
self.waiting_for_keyframe = True
self.clear_pending_frames()
await self.server.request_keyframe(self.camera)
elif cmd == 3:
self.waiting_for_keyframe = True
self.clear_pending_frames()
await self.server.request_keyframe(self.camera)
async def send_h264(self, payload):
is_keyframe = self.server.is_h264_keyframe(payload)
if self.waiting_for_keyframe:
if not is_keyframe:
return
self.waiting_for_keyframe = False
now = time.monotonic()
if self.pending_frames and now - self.pending_frames[0][0] > REMOTE_MAX_LATENCY:
self.clear_pending_frames()
if not is_keyframe:
self.waiting_for_keyframe = True
await self.server.request_keyframe(self.camera)
return
self.pending_frames.append((now, payload))
self.pending_event.set()
def clear_pending_frames(self):
self.pending_frames.clear()
self.pending_event.clear()
async def send_loop(self):
while True:
await self.pending_event.wait()
while self.pending_frames:
queued_at, frame = self.pending_frames.popleft()
if time.monotonic() - queued_at > REMOTE_MAX_LATENCY:
self.waiting_for_keyframe = True
self.clear_pending_frames()
await self.server.request_keyframe(self.camera)
break
await asyncio.wait_for(
self.send_packet(1, frame),
timeout=REMOTE_MAX_LATENCY,
)
if not self.pending_frames:
self.pending_event.clear()
async def send_packet(self, cmd, payload):
if not self.connected or not self.writer:
return
packet = self.server.make_ingest_packet(cmd, payload)
async with self.write_lock:
await self.write_client_ws_frame(packet, opcode=0x2)
async def write_client_ws_frame(self, payload, opcode=0x2):
header = bytearray([0x80 | opcode])
length = len(payload)
if length <= 125:
header.append(0x80 | length)
elif length <= 0xffff:
header.extend((0x80 | 126, *struct.pack("!H", length)))
else:
header.extend((0x80 | 127, *struct.pack("!Q", length)))
mask = os.urandom(4)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
self.writer.write(bytes(header) + mask + masked)
await self.writer.drain()
async def async_main():
server = MjpegWebsocketServer(PUSH_FPS)
await server.start_remote_clients()
tcp_server = await asyncio.start_server(server.handle_client, HOST, PORT)
print(f"H.264 websocket server listening on http://{HOST}:{PORT}, fps={PUSH_FPS:g}")
async with tcp_server:
await tcp_server.serve_forever()
def main():
asyncio.run(async_main())
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+180 -1
View File
@@ -176,11 +176,179 @@
function showSentry() {
did = localStorage.getItem("dongleId");
url = "FRP_SVR/"+String(did)
url = "http://192.168.1.113:10201/"+String(did)
console.log(url)
window.location = url;
}
let h264Ws = null;
let h264Decoder = null;
let h264Configured = false;
let h264Timestamp = 0;
let h264Frames = 0;
function findH264NalUnits(data) {
const units = [];
let start = -1;
let index = 0;
while (index + 3 < data.length) {
let prefixLength = 0;
if (data[index] === 0 && data[index + 1] === 0 && data[index + 2] === 1) {
prefixLength = 3;
} else if (index + 4 < data.length &&
data[index] === 0 && data[index + 1] === 0 &&
data[index + 2] === 0 && data[index + 3] === 1) {
prefixLength = 4;
}
if (!prefixLength) {
index += 1;
continue;
}
if (start >= 0) {
units.push(data.subarray(start, index));
}
start = index + prefixLength;
index = start;
}
if (start >= 0 && start < data.length) {
units.push(data.subarray(start));
}
return units;
}
function inspectH264(data) {
let key = false;
let codec = null;
for (const nal of findH264NalUnits(data)) {
if (!nal.length) {
continue;
}
const type = nal[0] & 0x1f;
key = key || type === 5;
if (type === 7 && nal.length >= 4) {
codec = "avc1." + [nal[1], nal[2], nal[3]]
.map(function(value) { return value.toString(16).padStart(2, "0"); })
.join("").toUpperCase();
}
}
return {key: key, codec: codec};
}
function closeH264Decoder() {
if (h264Decoder) {
try {
h264Decoder.close();
} catch (_) {
}
}
h264Decoder = null;
h264Configured = false;
h264Timestamp = 0;
}
function createH264Decoder(camera) {
if (!("VideoDecoder" in window)) {
document.getElementById("h264Status").innerText = "WebCodecs H.264 is not supported";
return false;
}
const canvas = document.getElementById("h264Frame");
const context = canvas.getContext("2d");
h264Decoder = new VideoDecoder({
output: function(frame) {
if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
}
context.drawImage(frame, 0, 0, canvas.width, canvas.height);
frame.close();
h264Frames += 1;
document.getElementById("h264Status").innerText = "Live " + camera + " / " + h264Frames + " frames";
},
error: function(error) {
console.error("H.264 decoder error", error);
document.getElementById("h264Status").innerText = "H.264 decode error";
closeH264Decoder();
}
});
return true;
}
function playH264(camera) {
const dongleId = localStorage.getItem("dongleId");
if (!dongleId) {
alert("Please input dongle ID first.");
return;
}
stopH264();
document.getElementById("h264Panel").style.display = "";
document.getElementById("h264Status").innerText = "Connecting " + camera;
h264Frames = 0;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
h264Ws = new WebSocket(protocol + "//" + location.host + "/h264/stream?dongle_id=" + encodeURIComponent(dongleId) + "&camera=" + encodeURIComponent(camera));
h264Ws.binaryType = "arraybuffer";
h264Ws.onopen = function() {
document.getElementById("h264Status").innerText = "Waiting for H.264 keyframe " + camera;
};
h264Ws.onmessage = function(event) {
const data = new Uint8Array(event.data);
const info = inspectH264(data);
if (!h264Configured) {
if (!info.key || !info.codec || (!h264Decoder && !createH264Decoder(camera))) {
return;
}
try {
h264Decoder.configure({
codec: info.codec,
optimizeForLatency: true,
hardwareAcceleration: "prefer-hardware"
});
h264Configured = true;
} catch (error) {
console.error("H.264 decoder configure error", error);
document.getElementById("h264Status").innerText = "Unsupported H.264 stream";
closeH264Decoder();
return;
}
}
try {
h264Decoder.decode(new EncodedVideoChunk({
type: info.key ? "key" : "delta",
timestamp: h264Timestamp,
data: data
}));
h264Timestamp += 50000;
} catch (error) {
console.error("H.264 decode submission error", error);
closeH264Decoder();
}
};
h264Ws.onclose = function() {
document.getElementById("h264Status").innerText = "Stopped";
};
h264Ws.onerror = function() {
h264Ws.close();
};
recordOperationTime();
}
function stopH264() {
if (h264Ws) {
h264Ws.onclose = null;
h264Ws.close();
h264Ws = null;
}
closeH264Decoder();
const canvas = document.getElementById("h264Frame");
if (canvas) {
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
}
const status = document.getElementById("h264Status");
if (status) {
status.innerText = "Stopped";
}
}
function recordOperationTime(){
strSec = Math.floor(Date.now() / 1000)
@@ -238,9 +406,20 @@
<input type="button" value="AMap" class="button button1" onclick = "showAmap();" />
&nbsp;&nbsp;
<input type="button" value="Nav" class="button button1" onclick = "showNavigation();" />
</br></br>
<input type="button" value="Road Cam" class="button button1" onclick = "playH264('roadCameraState');" />
&nbsp;&nbsp;
<input type="button" value="Wide Cam" class="button button1" onclick = "playH264('wideRoadCameraState');" />
&nbsp;&nbsp;
<input type="button" value="Stop Cam" class="button button1" onclick = "stopH264();" />
</div>
</br></br></br></br>
<div id="h264Panel" style="display:none;text-align:center;margin:0 auto;max-width:960px;">
<div id="h264Status" style="margin-bottom:8px;">Stopped</div>
<canvas id="h264Frame" aria-label="H.264 preview" style="width:100%;background:#000;min-height:240px;object-fit:contain;"></canvas>
</div>
<div style="text-align:center"><label style="display: none;" id="lastActive" > </label ></div>
+525 -168
View File
@@ -11,30 +11,41 @@
#container {
height: 100%;width: 100%;font-family: "微软雅黑";
}
.input-card .btn {margin-right: 1.2rem;width: 9rem;}
.input-card {width: auto;min-width: 0;padding: .45rem .55rem;display: grid;grid-template-columns: 9.2rem 14.2rem 3.8rem 5rem;align-items: center;gap: .35rem;white-space: nowrap;}
.input-card .input-item {height: auto;margin: 0;display: flex;align-items: center;gap: .25rem;min-width: 0;}
.input-card .btn {margin-right: 0;width: auto;min-width: 3.2rem;padding: 0 .35rem;height: 2.25rem;line-height: 2.25rem;}
.input-card .btn:last-child {margin-right: 0;}
#myTime {width: 9rem;height: 2.25rem;box-sizing: border-box;padding: 0 .25rem;line-height: normal;}
#tripSelect {width: 14rem;height: 2.25rem;box-sizing: border-box;min-width: 0;padding: .25rem 1.75rem .25rem .35rem;line-height: normal;}
.amap-marker-label{border: 0;background-color: transparent;}
.amap-marker-content img {width: 25px;height: 34px;}
.info {position: relative;top: 0;right: 0;min-width: 0;font-size: 16px;border: 1px solid rgb(204, 204, 204);}
.info {position: relative;top: 0;right: 0;min-width: 0;font-size: 16px;border: 1px solid rgb(204, 204, 204);white-space: nowrap;padding: 0 4px;}
#carGpsTip {position: fixed;left: calc(50% + 18px);top: calc(50% - 34px);z-index: 10000;display: none;white-space: nowrap;font-size: 16px;border: 1px solid rgb(204, 204, 204);background: #fff;padding: 0 4px;pointer-events: none;}
#loadStatus {position: fixed;left: 50%;top: 1rem;transform: translateX(-50%);z-index: 10001;padding: .35rem .65rem;background: rgba(255,255,255,.94);border: 1px solid #ccc;font-size: 13px;white-space: nowrap;}
@media (max-width: 760px) {
.input-card {max-width: calc(100% - 1rem);grid-template-columns: 8rem 10rem 3.2rem 4.5rem;gap: .25rem;overflow-x: auto;}
.input-card .btn {min-width: 2.8rem;padding: 0 .25rem;font-size: 12px;}
#myTime {width: 7.8rem;font-size: 12px;}
#tripSelect {width: 9.8rem;font-size: 12px;}
}
</style></head><body>
<div id="container"></div>
<div id="carGpsTip"></div>
<div id="loadStatus">GPS数据加载中...</div>
<div class="input-card">
<div class="input-item">
<input type="date" style="direction: rtl" onchange="onDataChange()" id="myTime"/>
</div>
<div class="input-item">
<input type="button" class="btn" value="开始动画" id="start" onclick="startAnimation()"/>
<input type="button" class="btn" value="暂停动画" id="pause" onclick="pauseAnimation()"/>
<select id="tripSelect" onchange="selectTrip(this.value)"></select>
</div>
<!--<div class="input-item">
<input type="button" class="btn" value="继续动画" id="resume" onclick="resumeAnimation()"/>
<input type="button" class="btn" value="停止动画" id="stop" onclick="stopAnimation()"/>
</div>-->
<div class="input-item">
<input type="button" class="btn" value="显示时间" id="showDate" onclick="showDate()"/>
<input type="button" class="btn" value="隐藏时间" id="hideDate" onclick="hideDate()"/>
<input type="button" class="btn" value="播放" id="playToggle" onclick="toggleAnimation()"/>
</div>
<div class="input-item">
<input type="button" class="btn" value="隐藏GPS" id="gpsToggle" onclick="toggleGpsPoints()"/>
</div>
</div>
@@ -42,230 +53,576 @@
<script>
document.getElementById("myTime").value= '_DATE_';
var replayDongleId = '_DONGLE_ID_';
function onDataChange() {
var oTimer = document.getElementById("myTime");
var value = oTimer.value;
dongleid = localStorage.getItem("dongleId");
var dongleid = replayDongleId || localStorage.getItem("dongleId");
window.location= "/replaygps/"+dongleid+"/"+value;
}
</script>
<script>
//只要起点 和 终点的 经纬度信息
var allData = '_GPS_DATA_';
var lineArr;
var polyline;
var lineArr = [];
var passedPolyline;
//全部的点
var path = [];
//是否显示时间
var showStatus = true;
//途经点
var wayMarker;
//初始化中心坐标
var centerGPS = _GPS_CENTER_;
//途经点数组。方便清空数据
var tripPolylines = [];
var marker;
var startMarker;
var endMarker;
var selectedTripIndex = 0;
var showPointStatus = true;
var animationPlaying = false;
var currentGpsPointIndex = -1;
var currentGpsLabelMarker;
var currentTimeLabel;
var wayMarkerArray = new Array();
var carStart, carEnd;
//创建地图。中心点、缩放等级
var tripPaths = [];
var rows = [];
var gpsRenderVersion = 0;
var map = new AMap.Map("container", {
resizeEnable: true,
center: [centerGPS[0], centerGPS[1]],
center: [114.03, 22.54],
zoom: 17
});
//驾车策略基础信息
var drivingOption = {
policy: AMap.DrivingPolicy.LEAST_DISTANCE,
ferry: 1,
}
var gpsData = JSON.parse(allData)
var rowLength = gpsData.data.rows.length;
var gpsLength = gpsData.data.rows[rowLength - 1].gpsInfos.length;
for(var i=0;i<rowLength;i++){
wayMarkerArray[i] = new Array();
}
//添加小汽车
carStart = [gpsData.data.rows[0].gpsInfos[0].longitude, gpsData.data.rows[0].gpsInfos[0].latitude]
carEnd = [gpsData.data.rows[rowLength - 1].gpsInfos[gpsLength - 1].longitude, gpsData.data.rows[rowLength - 1].gpsInfos[gpsLength - 1].latitude]
addCar('');
loadGpsData();
for (var i = 0; i < rowLength; i++) {
getRoute(i);
}
function getRoute(i) {
var data = gpsData.data.rows[i];
addMarker(i);
pts = getWayPoints(data.gpsInfos);
drawTrack(pts);
drawPassedPolyLine();
lineArr = pts;
return
}
function getWayPoints(coords) {
var GPS = new Array();
if (coords.length > 1) {
//不要第一个和最后一个,会导致起点终点图标被覆盖
for (var i = 1; i < coords.length - 1; i++) {
var wayPoint = coords[i];
var wayPointGPS = new AMap.LngLat(wayPoint.longitude, wayPoint.latitude)
GPS.push(wayPointGPS);
async function loadGpsData() {
setControlsDisabled(true);
setLoadStatus("GPS数据加载中...");
try {
var date = document.getElementById("myTime").value;
var url = "/replaygpsdata/" + encodeURIComponent(replayDongleId) + "/" + encodeURIComponent(date);
var response = await fetch(url, {cache: "no-store"});
if (!response.ok) {
throw new Error("GPS数据加载失败");
}
var gpsData = await response.json();
rows = gpsData.data && gpsData.data.rows ? gpsData.data.rows : [];
if (gpsData.date) {
document.getElementById("myTime").value = gpsData.date;
}
if (rows.length === 0) {
setLoadStatus("当天没有GPS数据");
return;
}
var firstPoint = rows[0].gpsInfos && rows[0].gpsInfos[0];
if (firstPoint) {
map.setCenter([firstPoint.longitude, firstPoint.latitude]);
}
setLoadStatus("正在生成行程...");
await buildTripPaths();
initTripSelect();
drawPassedPolyLine();
await drawAllTrips();
selectTrip(0);
setLoadStatus("");
} catch (error) {
console.error(error);
setLoadStatus(error.message || "GPS数据加载失败");
} finally {
setControlsDisabled(false);
}
return GPS;
}
// 绘制轨迹
function drawTrack(path) {
color = "#"+parseInt(Math.random()*65536).toString(16)+ "5b"
polyline = new AMap.Polyline({
map: map,
path: path,
showDir: true,
strokeColor: color,//"#18a45b", //线颜色
strokeWeight: 6, //线宽
function setControlsDisabled(disabled) {
document.getElementById("tripSelect").disabled = disabled;
document.getElementById("playToggle").disabled = disabled;
document.getElementById("gpsToggle").disabled = disabled;
}
function setLoadStatus(text) {
var status = document.getElementById("loadStatus");
status.innerText = text;
status.style.display = text ? "block" : "none";
}
function nextFrame() {
return new Promise(function (resolve) {
requestAnimationFrame(resolve);
});
}
async function buildTripPaths() {
var processed = 0;
for (var i = 0; i < rows.length; i++) {
var points = rows[i].gpsInfos || [];
var path = [];
for (var j = 0; j < points.length; j++) {
path.push(new AMap.LngLat(points[j].longitude, points[j].latitude));
processed++;
if (processed % 1000 === 0) {
await nextFrame();
}
}
tripPaths.push(path);
}
}
function initTripSelect() {
var select = document.getElementById("tripSelect");
select.innerHTML = "";
for (var i = 0; i < rows.length; i++) {
var option = document.createElement("option");
option.value = i;
option.text = rows[i].pointDay;
select.appendChild(option);
}
}
async function drawAllTrips() {
for (var i = 0; i < tripPaths.length; i++) {
if (tripPaths[i].length < 2) {
tripPolylines[i] = null;
continue;
}
setLoadStatus("正在绘制行程 " + (i + 1) + "/" + tripPaths.length);
await drawTripSegments(i, tripPaths[i], rows[i].gpsInfos || []);
}
}
async function drawTripSegments(tripIndex, path, points) {
var base = new AMap.Polyline({
map: map,
path: path,
strokeColor: getTripColor(tripIndex),
strokeOpacity: tripIndex === selectedTripIndex ? 1 : 0.55,
strokeWeight: tripIndex === selectedTripIndex ? 16 : 11,
zIndex: 40
});
var speeds = [];
for (var i = 1; i < path.length; i++) {
var segPoints = [path[i - 1], path[i]];
var speed = getSegmentSpeed(points[i - 1], points[i]);
var speedSegment = new AMap.Polyline({
map: map,
path: segPoints,
showDir: true,
strokeColor: getSpeedColor(speed),
strokeOpacity: tripIndex === selectedTripIndex ? 1 : 0.45,
strokeWeight: tripIndex === selectedTripIndex ? 6 : 3,
zIndex: 50
});
speeds.push(speedSegment);
if (i % 200 === 0) {
await nextFrame();
}
}
tripPolylines[tripIndex] = {base: base, speeds: speeds};
}
function selectTrip(index) {
selectedTripIndex = parseInt(index, 10) || 0;
if (!rows[selectedTripIndex] || tripPaths[selectedTripIndex].length === 0) {
return;
}
stopAnimation();
setAnimationPlaying(false);
lineArr = tripPaths[selectedTripIndex];
setTripHighlight();
resetCar();
resetStartEndMarkers();
renderGpsPoints();
currentGpsPointIndex = -1;
clearCurrentTimeLabel();
hideCarGpsTip();
passedPolyline.setPath([]);
var fitItems = [];
var tripLines = tripPolylines[selectedTripIndex];
if (tripLines) {
fitItems.push(tripLines.base);
for (var i = 0; i < tripLines.speeds.length; i++) {
fitItems.push(tripLines.speeds[i]);
}
}
fitItems.push(startMarker);
fitItems.push(endMarker);
map.setFitView(fitItems);
}
function setTripHighlight() {
for (var i = 0; i < tripPolylines.length; i++) {
var tripLines = tripPolylines[i];
if (!tripLines) {
continue;
}
tripLines.base.setOptions({
strokeOpacity: i === selectedTripIndex ? 1 : 0.55,
strokeWeight: i === selectedTripIndex ? 16 : 11
});
for (var j = 0; j < tripLines.speeds.length; j++) {
tripLines.speeds[j].setOptions({
strokeOpacity: i === selectedTripIndex ? 1 : 0.45,
strokeWeight: i === selectedTripIndex ? 6 : 3
});
}
}
}
function resetCar() {
if (marker) {
marker.setMap(null);
}
marker = new AMap.Marker({
map: map,
zIndex: 9999,
position: lineArr[0],
icon: "https://lbsyun.baidu.com/jsdemo/img/car.png",
offset: new AMap.Pixel(-26, -13),
autoRotation: true
});
marker.on('moving', function (e) {
passedPolyline.setPath(e.passedPath);
focusMapOnCar();
showCurrentGpsTime();
});
}
function resetStartEndMarkers() {
if (startMarker) {
startMarker.setMap(null);
}
if (endMarker) {
endMarker.setMap(null);
}
startMarker = new AMap.Marker({
position: lineArr[0],
icon: 'https://webapi.amap.com/theme/v1.3/markers/n/start.png',
map: map
});
endMarker = new AMap.Marker({
position: lineArr[lineArr.length - 1],
icon: 'https://webapi.amap.com/theme/v1.3/markers/n/end.png',
map: map
});
}
// 绘制运动轨迹样式
function drawPassedPolyLine() {
passedPolyline = new AMap.Polyline({
map: map,
strokeColor: "#AF5", //线颜色
strokeWeight: 6, //线宽
strokeColor: "#2b7fff",
strokeWeight: 6
});
}
/*
增加轨迹回放的小汽车和车牌
@param plate - 车牌
*/
function addCar(plate) {
if (plate == "")
{
marker = new AMap.Marker({
map: map,
zIndex: 9999,
position: [carStart[0], carStart[1]],
icon: "https://lbsyun.baidu.com/jsdemo/img/car.png",
offset: new AMap.Pixel(-26, -13),
autoRotation: true,
});
}
else
{
marker = new AMap.Marker({
map: map,
zIndex: 9999,
position: [carStart[0], carStart[1]],
icon: "https://lbsyun.baidu.com/jsdemo/img/car.png",
offset: new AMap.Pixel(-26, -13),
autoRotation: true,
label: {
content: "<div class='info'>" + plate + "</div>",
offset: new AMap.Pixel(-26, -35),
autoRotation: true
}
});
function showGpsPoints() {
showPointStatus = true;
updateGpsToggleText();
hideCarGpsTip();
renderGpsPoints();
if (animationPlaying) {
currentGpsPointIndex = -1;
showCurrentGpsTime();
}
}
// 显示时间
function showDate() {
showStatus = true;
for (var i = 0; i < rowLength; i++) {
addMarker(i);
function hideGpsPoints() {
showPointStatus = false;
currentGpsPointIndex = -1;
updateGpsToggleText();
renderGpsPoints();
if (animationPlaying) {
currentGpsPointIndex = -1;
showCurrentGpsTime();
}
}
// 隐藏时间
function hideDate() {
showStatus = false;
for (var i = 0; i < rowLength; i++) {
addMarker(i);
function toggleGpsPoints() {
if (showPointStatus) {
hideGpsPoints();
} else {
showGpsPoints();
}
}
// 实例化点标记
function addMarker(i) {
if (null != wayMarker) {
wayMarkerArray[i].forEach(function (wayMarkerPoint) {
wayMarkerPoint.setMap(null);
})
wayMarkerArray[i] = new Array();
function updateGpsToggleText() {
var button = document.getElementById("gpsToggle");
if (button) {
button.value = showPointStatus ? "隐藏GPS" : "显示GPS";
}
}
function clearGpsPoints() {
gpsRenderVersion++;
currentGpsLabelMarker = null;
wayMarkerArray.forEach(function (wayMarkerPoint) {
wayMarkerPoint.setMap(null);
});
wayMarkerArray = new Array();
}
async function renderGpsPoints() {
clearGpsPoints();
currentGpsPointIndex = -1;
if (!showPointStatus || !rows[selectedTripIndex]) {
return;
}
var data = gpsData.data.rows[i];
var renderVersion = gpsRenderVersion;
var data = rows[selectedTripIndex];
for (var j = 0; j < data.gpsInfos.length; j++) {
if (renderVersion !== gpsRenderVersion || !showPointStatus) {
return;
}
var gpsInfo = data.gpsInfos[j];
wayMarker = new AMap.Marker({
var wayMarker = new AMap.Marker({
map: map,
position: [gpsInfo.longitude, gpsInfo.latitude],
offset: new AMap.Pixel(-13, -30)
});
if (showStatus) {
wayMarker.setLabel({
offset: new AMap.Pixel(20, 20), //设置文本标注偏移量
content: "<div class='info'>" + gpsInfo.create_time + "</div>", //设置文本标注内容
direction: 'right' //设置文本标注方位
});
wayMarkerArray.push(wayMarker);
if (animationPlaying && showPointStatus && j === currentGpsPointIndex) {
currentGpsPointIndex = -1;
showCurrentGpsTime(j);
}
if ((j + 1) % 100 === 0) {
await nextFrame();
}
wayMarkerArray[i].push(wayMarker);
}
}
/**********起点ICON**********/
var startMarker = new AMap.Marker({
position: carStart,
icon: 'https://webapi.amap.com/theme/v1.3/markers/n/start.png',
map: map
})
/**********终点ICON**********/
var endMarker = new AMap.Marker({
position: carEnd,
icon: 'https://webapi.amap.com/theme/v1.3/markers/n/end.png',
map: map
})
// 调整视野达到最佳显示区域
map.setFitView([startMarker, endMarker])
marker.on('moving', function (e) {
passedPolyline.setPath(e.passedPath);
});
/**********动画 START**********/
function startAnimation() {
marker.moveAlong(lineArr, 992);
if (marker && lineArr.length > 1) {
setAnimationPlaying(true);
if (showPointStatus && wayMarkerArray.length === 0) {
renderGpsPoints();
}
focusMapOnCar();
showCurrentGpsTime(0);
marker.moveAlong(lineArr, 99200);
}
}
function pauseAnimation() {
marker.pauseMove();
if (marker) {
marker.pauseMove();
setAnimationPlaying(false);
hideCarGpsTip();
}
}
function toggleAnimation() {
if (animationPlaying) {
pauseAnimation();
} else {
startAnimation();
}
}
function setAnimationPlaying(isPlaying) {
animationPlaying = isPlaying;
var button = document.getElementById("playToggle");
if (button) {
button.value = animationPlaying ? "暂停" : "播放";
}
}
function resumeAnimation() {
marker.resumeMove();
if (marker) {
marker.resumeMove();
}
}
function stopAnimation() {
marker.stopMove();
if (marker) {
marker.stopMove();
}
setAnimationPlaying(false);
hideCarGpsTip();
}
/**********动画 END**********/
function focusMapOnCar() {
if (!marker) {
return;
}
// 解析DrivingRoute对象,构造成AMap.Polyline的path参数需要的格式
function parseRouteToPath(route, type) {
for (var i = 0, l = route.steps.length; i < l; i++) {
var step = route.steps[i]
var position = marker.getPosition();
if (position) {
map.setCenter(position);
}
}
for (var j = 0, n = step.path.length; j < n; j++) {
path.push(step.path[j])
function showCurrentGpsTime(forceIndex) {
if (!rows[selectedTripIndex]) {
return;
}
var index = typeof forceIndex === "number" ? forceIndex : getNearestGpsPointIndex(marker.getPosition());
if (index < 0) {
return;
}
if (showPointStatus && index === currentGpsPointIndex) {
return;
}
clearGpsPointLabels();
clearCurrentTimeLabel();
currentGpsPointIndex = index;
var gpsInfo = rows[selectedTripIndex].gpsInfos[index];
var clock = formatGpsClock(gpsInfo.create_time);
var content = "<div class='info'>" + clock + "</div>";
if (showPointStatus && wayMarkerArray[index]) {
hideCarGpsTip();
currentGpsLabelMarker = wayMarkerArray[index];
currentGpsLabelMarker.setLabel({
offset: new AMap.Pixel(20, 20),
content: content,
direction: 'right'
});
} else {
showCarGpsTip(clock);
}
}
function formatGpsClock(value) {
var parts = value.split(" ");
return parts.length > 1 ? parts[1] : value;
}
function clearGpsPointLabels() {
if (currentGpsLabelMarker) {
currentGpsLabelMarker.setLabel({
offset: new AMap.Pixel(20, 20),
content: "",
direction: 'right'
});
currentGpsLabelMarker = null;
}
}
function showCurrentTimeLabel(position, content) {
currentTimeLabel = new AMap.Marker({
map: map,
position: position,
content: "<div></div>",
offset: new AMap.Pixel(0, 0),
zIndex: 10000
});
currentTimeLabel.setLabel({
offset: new AMap.Pixel(20, -20),
content: content,
direction: 'right'
});
}
function clearCurrentTimeLabel() {
if (currentTimeLabel) {
currentTimeLabel.setMap(null);
currentTimeLabel = null;
}
}
function showCarGpsTip(clock) {
var tip = document.getElementById("carGpsTip");
if (tip) {
tip.innerText = clock;
tip.style.display = "block";
}
}
function hideCarGpsTip() {
var tip = document.getElementById("carGpsTip");
if (tip) {
tip.style.display = "none";
tip.innerText = "";
}
}
function getNearestGpsPointIndex(position) {
if (!position || lineArr.length === 0) {
return -1;
}
var nearestIndex = 0;
var nearestDistance = Number.MAX_VALUE;
for (var i = 0; i < lineArr.length; i++) {
var distance = position.distance(lineArr[i]);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = i;
}
}
return path
return nearestIndex;
}
function getSegmentSpeed(a, b) {
var start = parseGpsTime(a.create_time);
var end = parseGpsTime(b.create_time);
var seconds = (end - start) / 1000;
if (seconds <= 0) {
return 0;
}
return getDistance(a, b) / seconds;
}
function parseGpsTime(value) {
return new Date(value.replace(/-/g, "/")).getTime();
}
function getDistance(a, b) {
var earthRadius = 6371000;
var lat1 = toRad(parseFloat(a.latitude));
var lat2 = toRad(parseFloat(b.latitude));
var deltaLat = toRad(parseFloat(b.latitude) - parseFloat(a.latitude));
var deltaLng = toRad(parseFloat(b.longitude) - parseFloat(a.longitude));
var h = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2);
return earthRadius * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function toRad(value) {
return value * Math.PI / 180;
}
function getSpeedColor(speed) {
var maxSpeed = 20;
var ratio = Math.max(0, Math.min(speed / maxSpeed, 1));
var red = Math.round(214 * (1 - ratio) + 25 * ratio);
var green = Math.round(69 * (1 - ratio) + 165 * ratio);
var blue = Math.round(49 * (1 - ratio) + 90 * ratio);
return rgbToHex(red, green, blue);
}
function getTripColor(index) {
var colors = [
"#2563eb", "#9333ea", "#f97316", "#0891b2",
"#db2777", "#7c3aed", "#0f766e", "#ca8a04"
];
return colors[index % colors.length];
}
function rgbToHex(red, green, blue) {
return "#" + [red, green, blue].map(function (value) {
var hex = value.toString(16);
return hex.length === 1 ? "0" + hex : hex;
}).join("");
}
// 保留旧按钮或缓存页面调用,避免旧事件找不到函数。
function showDate() {
showGpsPoints();
}
function hideDate() {
hideGpsPoints();
}
function parseRouteToPath(route, type) {
var path = [];
for (var i = 0, l = route.steps.length; i < l; i++) {
var step = route.steps[i];
for (var j = 0, n = step.path.length; j < n; j++) {
path.push(step.path[j]);
}
}
return path;
}
</script>
</body>