Committed and Pushed by ButtonClick
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
public/snapshots/
|
||||||
BIN
data/settings/callibration_cam0.npz
Executable file
BIN
data/settings/callibration_cam0.npz
Executable file
Binary file not shown.
BIN
data/settings/callibration_cam0_720.npz
Executable file
BIN
data/settings/callibration_cam0_720.npz
Executable file
Binary file not shown.
BIN
data/settings/callibration_cam1.npz
Executable file
BIN
data/settings/callibration_cam1.npz
Executable file
Binary file not shown.
BIN
data/settings/callibration_cam1_720.npz
Executable file
BIN
data/settings/callibration_cam1_720.npz
Executable file
Binary file not shown.
BIN
data/settings/camera_intrinsics_v0.npz
Executable file
BIN
data/settings/camera_intrinsics_v0.npz
Executable file
Binary file not shown.
BIN
data/settings/camera_intrinsics_v1.npz
Executable file
BIN
data/settings/camera_intrinsics_v1.npz
Executable file
Binary file not shown.
10
data/settings/settings.json
Executable file
10
data/settings/settings.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{ "coordinateSystem":{
|
||||||
|
"MarkersUsed":"DICT_4X4_250",
|
||||||
|
"KnownMarkers":
|
||||||
|
{
|
||||||
|
"50": [0.0, 0.0, 0.0],
|
||||||
|
"71": [0.140, 0.0, 0.0],
|
||||||
|
"101": [0.0, -0.080, 0.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
data/settings/settingsBoard.json
Executable file
10
data/settings/settingsBoard.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{ "coordinateSystem":{
|
||||||
|
"MarkersUsed":"DICT_4X4_250",
|
||||||
|
"KnownMarkers":
|
||||||
|
{
|
||||||
|
"50": [0.0, 0.0, 0.0],
|
||||||
|
"71": [0.140, 0.0, 0.0],
|
||||||
|
"101": [0.0, -0.080, 0.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
data/settings/settingsMachine.json
Executable file
10
data/settings/settingsMachine.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{ "coordinateSystem":{
|
||||||
|
"MarkersUsed":"DICT_4X4_250",
|
||||||
|
"KnownMarkers":
|
||||||
|
{
|
||||||
|
"58": [0.0, 0.0, 0.0],
|
||||||
|
"65": [0.161, 0.0, 0.0],
|
||||||
|
"75": [0.0, -0.070, 0.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
gitPush.sh
Executable file
4
gitPush.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "Committed and Pushed by ButtonClick"
|
||||||
|
git push
|
||||||
29
https/server.crt
Executable file
29
https/server.crt
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
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
|
||||||
|
-----END CERTIFICATE-----
|
||||||
52
https/server.key
Executable file
52
https/server.key
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
hwXVuAeFYYyuRFN8xJ9SkIPdpr1Y
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
1192
package-lock.json
generated
Executable file
1192
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
20
package.json
Executable file
20
package.json
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "video-streamer",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "HTTPS + WSS dual-camera MJPEG streamer (modular)",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "PORT=8443 nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
programs/driver.js
Executable file
71
programs/driver.js
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
const WebSocket = require("ws");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards WebSocket messages between browser clients (/robot)
|
||||||
|
* and a target WebSocket server (behind a firewall).
|
||||||
|
*
|
||||||
|
* @param {WebSocket.Server} wssInput - Local WebSocket server for browser clients
|
||||||
|
* @param {string} targetUrl - URL of target WebSocket server, e.g. "wss://internal.local:8080"
|
||||||
|
*/
|
||||||
|
function setupCommandForwarding(wssInput, targetUrl) {
|
||||||
|
let targetSocket;
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
function connectTarget() {
|
||||||
|
console.log(`🔌 Connecting to target server: ${targetUrl}`);
|
||||||
|
targetSocket = new WebSocket(targetUrl);
|
||||||
|
|
||||||
|
targetSocket.on("open", () => {
|
||||||
|
console.log("✅ Connected to target server");
|
||||||
|
});
|
||||||
|
|
||||||
|
targetSocket.on("message", (msg) => {
|
||||||
|
const data = msg.toString();
|
||||||
|
console.log("⬅️ Message from target:", data);
|
||||||
|
// Broadcast to all connected browsers
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
targetSocket.on("close", () => {
|
||||||
|
console.warn("⚠️ Target connection closed. Reconnecting in 5s...");
|
||||||
|
setTimeout(connectTarget, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
targetSocket.on("error", (err) => {
|
||||||
|
console.error("❌ Target connection error:", err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectTarget();
|
||||||
|
|
||||||
|
// When a browser connects to /robot
|
||||||
|
wssInput.on("connection", (ws, req) => {
|
||||||
|
console.log("🤖 Browser connected:", req.socket.remoteAddress);
|
||||||
|
clients.add(ws);
|
||||||
|
|
||||||
|
ws.on("message", (msg) => {
|
||||||
|
const data = msg.toString();
|
||||||
|
console.log("➡️ From browser → target:", data);
|
||||||
|
if (targetSocket?.readyState === WebSocket.OPEN) {
|
||||||
|
targetSocket.send(data);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ Target not connected. Message dropped.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
clients.delete(ws);
|
||||||
|
console.log("🔌 Browser disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
console.error("❌ Browser socket error:", err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupCommandForwarding };
|
||||||
49
programs/input.js
Executable file
49
programs/input.js
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
// programs/input.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function byIdCaptureCandidates() {
|
||||||
|
const dir = '/dev/v4l/by-id';
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter(n => n.endsWith('-index0'))
|
||||||
|
.map(n => fs.realpathSync(path.join(dir, n)));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function naiveVideoNodes() {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync('/dev')
|
||||||
|
.filter(n => /^video\d+$/.test(n))
|
||||||
|
.map(n => path.join('/dev', n))
|
||||||
|
.sort((a, b) => Number(a.replace(/\D/g, '')) - Number(b.replace(/\D/g, '')));
|
||||||
|
} catch {
|
||||||
|
return ['/dev/video0', '/dev/video2'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDevices(env = process.env) {
|
||||||
|
const DEV0 = env.DEV0 || null;
|
||||||
|
const DEV1 = env.DEV1 || null;
|
||||||
|
|
||||||
|
if (DEV0 && DEV1) return [DEV0, DEV1];
|
||||||
|
|
||||||
|
const byId = byIdCaptureCandidates();
|
||||||
|
if (DEV0 || DEV1) {
|
||||||
|
const pool = byId.length ? byId : naiveVideoNodes();
|
||||||
|
const d0 = DEV0 || pool[0];
|
||||||
|
const d1 = DEV1 || pool.find(d => d !== d0) || pool[1];
|
||||||
|
return [d0, d1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byId.length >= 2) return [byId[0], byId[1]];
|
||||||
|
const naive = naiveVideoNodes();
|
||||||
|
return [naive[0], naive[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pickDevices };
|
||||||
165
programs/log.js
Executable file
165
programs/log.js
Executable file
@@ -0,0 +1,165 @@
|
|||||||
|
// programs/log.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// --- configuration ---
|
||||||
|
const LOG_DIR = path.join(__dirname, '..', 'logs');
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
function getLogFilePath(d = new Date()) {
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return path.join(LOG_DIR, `${yyyy}_${mm}_${dd}.txt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(obj) {
|
||||||
|
const line = JSON.stringify(obj) + '\n';
|
||||||
|
fs.appendFile(getLogFilePath(), line, (err) => {
|
||||||
|
if (err) console.error('[log] write error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- common extractors ---
|
||||||
|
function commonFromReq(req) {
|
||||||
|
try {
|
||||||
|
const xff = req?.headers?.['x-forwarded-for'];
|
||||||
|
const xRealIp = req?.headers?.['x-real-ip'];
|
||||||
|
const ipFromXff = xff ? xff.split(',')[0].trim() : null;
|
||||||
|
const ip =
|
||||||
|
ipFromXff ||
|
||||||
|
xRealIp ||
|
||||||
|
req?.ip ||
|
||||||
|
req?.socket?.remoteAddress ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const tls =
|
||||||
|
req?.socket?.encrypted
|
||||||
|
? {
|
||||||
|
protocol:
|
||||||
|
typeof req.socket.getProtocol === 'function'
|
||||||
|
? req.socket.getProtocol()
|
||||||
|
: null,
|
||||||
|
cipher:
|
||||||
|
typeof req.socket.getCipher === 'function'
|
||||||
|
? (req.socket.getCipher() || {}).name
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// MAC is not available across routed networks
|
||||||
|
const mac = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
ips: Array.isArray(req?.ips) ? req.ips : [],
|
||||||
|
xff: xff || null,
|
||||||
|
remoteAddress: req?.socket?.remoteAddress || null,
|
||||||
|
remoteFamily: req?.socket?.remoteFamily || null,
|
||||||
|
userAgent: req?.headers?.['user-agent'] || null,
|
||||||
|
acceptLanguage: req?.headers?.['accept-language'] || null,
|
||||||
|
secChUa: req?.headers?.['sec-ch-ua'] || null,
|
||||||
|
secChUaPlatform: req?.headers?.['sec-ch-ua-platform'] || null,
|
||||||
|
secChUaMobile: req?.headers?.['sec-ch-ua-mobile'] || null,
|
||||||
|
referer: req?.headers?.['referer'] || null,
|
||||||
|
tls,
|
||||||
|
mac,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonFromSocket(socket) {
|
||||||
|
return {
|
||||||
|
remoteAddress: socket?.remoteAddress || null,
|
||||||
|
remoteFamily: socket?.remoteFamily || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- specific log functions ---
|
||||||
|
function logHttpRequest(req) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'http',
|
||||||
|
method: req?.method || null,
|
||||||
|
url: (req?.originalUrl ?? req?.url) || null,
|
||||||
|
...commonFromReq(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTcpConnection(socket) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'tcp',
|
||||||
|
...commonFromSocket(socket),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logHttpUpgrade(req) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'http-upgrade',
|
||||||
|
url: req?.url || null,
|
||||||
|
...commonFromReq(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWssConnected(req) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'wss',
|
||||||
|
url: req?.url || null,
|
||||||
|
...commonFromReq(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWssClosed(req, code, reason) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'wss-close',
|
||||||
|
url: req?.url || null,
|
||||||
|
code: typeof code === 'number' ? code : null,
|
||||||
|
reason: reason ? reason.toString() : null,
|
||||||
|
...commonFromReq(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSnapshot(python, response){
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'snapshot',
|
||||||
|
command: python.toString(),
|
||||||
|
wsResponse: response.toString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- generic hooks you requested ---
|
||||||
|
function connected(context = {}) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'connected',
|
||||||
|
...context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionLost(context = {}) {
|
||||||
|
write({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'connection-lost',
|
||||||
|
...context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
logHttpRequest,
|
||||||
|
logTcpConnection,
|
||||||
|
logHttpUpgrade,
|
||||||
|
logWssConnected,
|
||||||
|
logSnapshot,
|
||||||
|
logWssClosed,
|
||||||
|
connected,
|
||||||
|
connectionLost,
|
||||||
|
};
|
||||||
369
programs/readCamPos.py
Executable file
369
programs/readCamPos.py
Executable file
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ArUco detection with multi-marker machine-frame fit + camera pose output (OpenCV >= 4.8).
|
||||||
|
|
||||||
|
- Reads: webCam_1.jpg
|
||||||
|
- Detects DICT_4X4_250 markers (ids expected: 0, 5, 10, 15)
|
||||||
|
- Uses multiple reference markers with known machine coordinates to fit camera->machine transform
|
||||||
|
- Reports positions/orientations of all markers **and the camera** in machine coordinates
|
||||||
|
- Draws detected markers, per-marker axes, and the machine axes
|
||||||
|
- Saves: webCam_1a.jpg (annotated) and marker_poses_machine.csv (poses incl. camera)
|
||||||
|
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 readCamPos.py -i snapshot_video1_1764493534200.jpg -npz camera_intrinsics_v0.npz -setting settings.json
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
import faulthandler
|
||||||
|
faulthandler.enable()
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Tuple, Dict, List
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------- Configuration Defaults -----------------------
|
||||||
|
IMAGE_PATH = "default.jpg"
|
||||||
|
OUTPUT_IMAGE_PATH = "default.jpg"
|
||||||
|
OUTPUT_CSV_PATH = "default.csv"
|
||||||
|
OUTPUT_JSON_PATH = "default.json"
|
||||||
|
|
||||||
|
# Marker side length in meters (25 mm)
|
||||||
|
MARKER_LENGTH_M = 0.025
|
||||||
|
|
||||||
|
# Axis lengths for visualization (in meters)
|
||||||
|
AXIS_LENGTH_M = 0.05 # per-marker axis
|
||||||
|
MACHINE_AXIS_X_M = 0.200 # 200 mm along +X
|
||||||
|
MACHINE_AXIS_Y_M = -0.100 # -100 mm along Y (towards camera per description)
|
||||||
|
MACHINE_AXIS_Z_M = 0.100 # +Z visualized as 100 mm
|
||||||
|
|
||||||
|
# Known machine coordinates for reference markers (meters)
|
||||||
|
cam_anchor_pts = {}
|
||||||
|
|
||||||
|
EXPECTED_IDS = {50, 71, 101}
|
||||||
|
|
||||||
|
# ----------------------- Utilities -----------------------
|
||||||
|
|
||||||
|
def load_intrinsics_npz(npz_path: str) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
if os.path.exists(npz_path):
|
||||||
|
|
||||||
|
print("NPZ from File:", npz_path)
|
||||||
|
data = np.load(npz_path)
|
||||||
|
for k in ('camera_matrix', 'mtx', 'K'):
|
||||||
|
if k in data:
|
||||||
|
camera_matrix = data[k].astype(np.float32)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise KeyError("Camera matrix not found.")
|
||||||
|
for k in ('dist_coeffs', 'dist', 'D'):
|
||||||
|
if k in data:
|
||||||
|
dist = data[k].astype(np.float32).reshape(-1, 1)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
dist = np.zeros((5, 1), dtype=np.float32)
|
||||||
|
return camera_matrix, dist
|
||||||
|
|
||||||
|
camera_matrix = np.array([[1400, 0, 640],
|
||||||
|
[0, 1400, 360],
|
||||||
|
[0, 0, 1]], dtype=np.float32)
|
||||||
|
|
||||||
|
dist_coeffs = np.zeros((5, 1), dtype=np.float32)
|
||||||
|
|
||||||
|
print("[WARN] Using default approximate intrinsics.")
|
||||||
|
return camera_matrix, dist_coeffs
|
||||||
|
|
||||||
|
|
||||||
|
def rvec_to_R(rvec: np.ndarray) -> np.ndarray:
|
||||||
|
R, _ = cv2.Rodrigues(rvec)
|
||||||
|
return R
|
||||||
|
|
||||||
|
|
||||||
|
def R_to_euler_zyx(R: np.ndarray) -> Tuple[float, float, float]:
|
||||||
|
yaw = float(np.degrees(np.arctan2(R[1,0], R[0,0])))
|
||||||
|
sp = np.sqrt(R[2,1]**2 + R[2,2]**2)
|
||||||
|
pitch = float(np.degrees(np.arctan2(-R[2,0], sp)))
|
||||||
|
roll = float(np.degrees(np.arctan2(R[2,1], R[2,2])))
|
||||||
|
return roll, pitch, yaw
|
||||||
|
|
||||||
|
|
||||||
|
def corners_to_image_points(corners: np.ndarray) -> np.ndarray:
|
||||||
|
return corners.reshape(4, 2).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def get_detector():
|
||||||
|
dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
|
||||||
|
try:
|
||||||
|
params = cv2.aruco.DetectorParameters()
|
||||||
|
except Exception:
|
||||||
|
params = cv2.aruco.DetectorParameters_create()
|
||||||
|
try:
|
||||||
|
detector = cv2.aruco.ArucoDetector(dictionary, params)
|
||||||
|
return detector, None
|
||||||
|
except Exception:
|
||||||
|
return None, (dictionary, params)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_markers(image: np.ndarray, detector_tuple):
|
||||||
|
detector, fallback = detector_tuple
|
||||||
|
print(detector)
|
||||||
|
if detector is not None:
|
||||||
|
corners, ids, rejected = detector.detectMarkers(image)
|
||||||
|
else:
|
||||||
|
dictionary, params = fallback
|
||||||
|
corners, ids, rejected = cv2.aruco.detectMarkers(image, dictionary, parameters=params)
|
||||||
|
return corners, ids, rejected
|
||||||
|
|
||||||
|
|
||||||
|
def rigid_transform_no_scale(A: np.ndarray, B: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
assert A.shape == B.shape and A.shape[1] == 3, "A and B must be Nx3"
|
||||||
|
N = A.shape[0]
|
||||||
|
if N < 2:
|
||||||
|
raise ValueError("Need at least 2 points; 3+ recommended.")
|
||||||
|
centroid_A = A.mean(axis=0)
|
||||||
|
centroid_B = B.mean(axis=0)
|
||||||
|
AA = A - centroid_A
|
||||||
|
BB = B - centroid_B
|
||||||
|
H = AA.T @ BB
|
||||||
|
U, S, Vt = np.linalg.svd(H)
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
if np.linalg.det(R) < 0:
|
||||||
|
Vt[-1, :] *= -1
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
t = centroid_B - R @ centroid_A
|
||||||
|
return R.astype(np.float32), t.astype(np.float32)
|
||||||
|
|
||||||
|
def readSettings(fileSetting):
|
||||||
|
global cam_anchor_pts
|
||||||
|
print("Read Settings")
|
||||||
|
|
||||||
|
if(fileSetting == None):
|
||||||
|
cam_anchor_pts = {
|
||||||
|
50: np.array([0.0, 0.0, 0.0], dtype=np.float32),
|
||||||
|
71: np.array([0.140, 0.0, 0.0], dtype=np.float32),
|
||||||
|
101: np.array([-0.0, -0.080, 0.0], dtype=np.float32),
|
||||||
|
#15: np.array([20,20,20]) # add if known
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
with open(fileSetting, 'r') as f:
|
||||||
|
settings = json.load(f)
|
||||||
|
for marker_id, coords in settings['coordinateSystem']['KnownMarkers'].items():
|
||||||
|
cam_anchor_pts[int(marker_id)] = np.array(coords, dtype=np.float32)
|
||||||
|
|
||||||
|
#KNOWN_MACHINE_POS = {int(k): np.array(v, dtype=np.float32) for k, v in settings.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------- Main -----------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Detect ArUco markers in two images and compute camera poses in machine coordinates.")
|
||||||
|
parser.add_argument('-i', '--images', action='append', required=False,
|
||||||
|
help="Path to image. Provide this option twice: once per camera (e.g., -i2 cam1.jpg -i2 cam2.jpg)")
|
||||||
|
parser.add_argument('-npz', '--npz', action='append', required=False, default=['camera_intrinsics_v1.npz'])
|
||||||
|
parser.add_argument('--cam-calib', action='append', required=False,
|
||||||
|
help="Paths to calibration YAMLs for camera 1 and camera 2 (e.g., cam1.npz cam2.npz)")
|
||||||
|
parser.add_argument('--marker-size-mm', type=float, default=25,
|
||||||
|
help="Marker side length in millimeters (e.g., 50)")
|
||||||
|
parser.add_argument('--dict', default='DICT_4X4_250',
|
||||||
|
help="ArUco dictionary name (default: DICT_4X4_250)")
|
||||||
|
parser.add_argument('-settings', type=str, default=None,
|
||||||
|
help="Json File with Machine Settings")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
print("ABC 0")
|
||||||
|
|
||||||
|
readSettings(args.settings)
|
||||||
|
|
||||||
|
print("ABC 0")
|
||||||
|
|
||||||
|
if(args.images is None):
|
||||||
|
image = cv2.imread(IMAGE_PATH)
|
||||||
|
else:
|
||||||
|
image = cv2.imread(args.images[0])
|
||||||
|
OUTPUT_IMAGE_PATH = args.images[0].replace(".jpg","r.jpg").replace(".PNG","r.PNG")
|
||||||
|
OUTPUT_CSV_PATH = args.images[0].replace(".jpg",".csv").replace(".PNG",".csv")
|
||||||
|
OUTPUT_JSON_PATH = args.images[0].replace(".jpg",".json").replace(".PNG",".json")
|
||||||
|
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
print(f"[ERROR] Cannot read image '{IMAGE_PATH}'.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
print("ABC 1")
|
||||||
|
|
||||||
|
camera_matrix, dist_coeffs = load_intrinsics_npz(args.npz[0])
|
||||||
|
print("ABC 1a")
|
||||||
|
detector_tuple = get_detector()
|
||||||
|
print("ABC 1b")
|
||||||
|
corners_list, ids, rejected = detect_markers(image, detector_tuple)
|
||||||
|
|
||||||
|
|
||||||
|
print("ABC 2")
|
||||||
|
|
||||||
|
if ids is None or len(ids) == 0:
|
||||||
|
print("[ERROR] No markers detected.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
draw_img = image.copy()
|
||||||
|
cv2.aruco.drawDetectedMarkers(draw_img, corners_list, ids)
|
||||||
|
|
||||||
|
half = MARKER_LENGTH_M / 2.0
|
||||||
|
obj_points = np.array([
|
||||||
|
[-half, half, 0.0],
|
||||||
|
[ half, half, 0.0],
|
||||||
|
[ half, -half, 0.0],
|
||||||
|
[-half, -half, 0.0],
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
poses_cam: Dict[int, Tuple[np.ndarray, np.ndarray]] = {}
|
||||||
|
centers_cam: Dict[int, np.ndarray] = {}
|
||||||
|
for i, marker_id in enumerate(ids.flatten()):
|
||||||
|
img_pts = corners_to_image_points(corners_list[i])
|
||||||
|
success, rvec, tvec = cv2.solvePnP(obj_points, img_pts, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_IPPE_SQUARE)
|
||||||
|
if not success:
|
||||||
|
success, rvec, tvec = cv2.solvePnP(obj_points, img_pts, camera_matrix, dist_coeffs)
|
||||||
|
if success:
|
||||||
|
rvec = rvec.reshape(3,1)
|
||||||
|
tvec = tvec.reshape(3,1)
|
||||||
|
poses_cam[int(marker_id)] = (rvec, tvec)
|
||||||
|
centers_cam[int(marker_id)] = tvec.flatten()
|
||||||
|
try:
|
||||||
|
cv2.drawFrameAxes(draw_img, camera_matrix, dist_coeffs, rvec, tvec, AXIS_LENGTH_M)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print(f"[WARN] solvePnP failed for marker {marker_id}")
|
||||||
|
|
||||||
|
common_ids: List[int] = [mid for mid in cam_anchor_pts.keys() if mid in centers_cam]
|
||||||
|
if len(common_ids) < 2:
|
||||||
|
print(f"[ERROR] Need at least 2 reference markers; found {len(common_ids)}: {common_ids}")
|
||||||
|
sys.exit(1)
|
||||||
|
if len(common_ids) < 3:
|
||||||
|
print(f"[WARN] Only {len(common_ids)} references ({common_ids}). Fit may be less stable; 3+ recommended.")
|
||||||
|
|
||||||
|
A = np.stack([centers_cam[mid] for mid in common_ids], axis=0)
|
||||||
|
B = np.stack([cam_anchor_pts[mid] for mid in common_ids], axis=0)
|
||||||
|
|
||||||
|
R_cam_to_machine, t_cam_to_machine = rigid_transform_no_scale(A, B)
|
||||||
|
|
||||||
|
residuals_mm = []
|
||||||
|
for i, mid in enumerate(common_ids):
|
||||||
|
pred = R_cam_to_machine @ A[i] + t_cam_to_machine
|
||||||
|
err = np.linalg.norm(pred - B[i]) * 1000.0
|
||||||
|
residuals_mm.append(err)
|
||||||
|
rms = float(np.sqrt(np.mean(np.square(residuals_mm)))) if residuals_mm else 0.0
|
||||||
|
print("\nReference fit residuals (mm) per marker:")
|
||||||
|
for mid, e in zip(common_ids, residuals_mm):
|
||||||
|
print(f" ID {mid}: {e:.2f} mm")
|
||||||
|
print(f"RMS residual: {rms:.2f} mm")
|
||||||
|
|
||||||
|
# Camera pose in machine coordinates:
|
||||||
|
# Camera origin (0,0,0 in camera) maps to t_cam_to_machine
|
||||||
|
cam_pos_machine = t_cam_to_machine
|
||||||
|
cam_R_machine = R_cam_to_machine # camera basis expressed in machine frame
|
||||||
|
cam_roll, cam_pitch, cam_yaw = R_to_euler_zyx(cam_R_machine)
|
||||||
|
|
||||||
|
rows = [("id", "x_mm", "y_mm", "z_mm", "roll_deg", "pitch_deg", "yaw_deg")]
|
||||||
|
marker_list = []
|
||||||
|
|
||||||
|
print("\nMarker Positions and Orientations in Machine Coordinates:")
|
||||||
|
print(f"{'ID':>8} {'X(mm)':>10} {'Y(mm)':>10} {'Z(mm)':>10} {'Roll':>10} {'Pitch':>10} {'Yaw':>10}")
|
||||||
|
|
||||||
|
# Add camera first
|
||||||
|
cx, cy, cz = (cam_pos_machine * 1000.0).tolist()
|
||||||
|
print(f"{'camera':>8} {cx:10.2f} {cy:10.2f} {cz:10.2f} {cam_roll:10.2f} {cam_pitch:10.2f} {cam_yaw:10.2f}")
|
||||||
|
rows.append(("camera", f"{cx:.3f}", f"{cy:.3f}", f"{cz:.3f}", f"{cam_roll:.3f}", f"{cam_pitch:.3f}", f"{cam_yaw:.3f}"))
|
||||||
|
camera_pose = {
|
||||||
|
"id": "camera",
|
||||||
|
"position_mm": [float(x) for x in cam_pos_machine * 1000.0],
|
||||||
|
"orientation_deg": {"roll": cam_roll, "pitch": cam_pitch, "yaw": cam_yaw}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Then markers
|
||||||
|
for marker_id in sorted(poses_cam.keys()):
|
||||||
|
rvec, tvec = poses_cam[marker_id]
|
||||||
|
R_marker_cam = rvec_to_R(rvec)
|
||||||
|
pos_machine = R_cam_to_machine @ tvec.flatten() + t_cam_to_machine
|
||||||
|
R_marker_machine = R_cam_to_machine @ R_marker_cam
|
||||||
|
roll_deg, pitch_deg, yaw_deg = R_to_euler_zyx(R_marker_machine)
|
||||||
|
x_mm, y_mm, z_mm = (pos_machine * 1000.0).tolist()
|
||||||
|
print(f"{marker_id:8d} {x_mm:10.2f} {y_mm:10.2f} {z_mm:10.2f} {roll_deg:10.2f} {pitch_deg:10.2f} {yaw_deg:10.2f}")
|
||||||
|
rows.append((marker_id, f"{x_mm:.3f}", f"{y_mm:.3f}", f"{z_mm:.3f}", f"{roll_deg:.3f}", f"{pitch_deg:.3f}", f"{yaw_deg:.3f}"))
|
||||||
|
marker_list.append({"id": marker_id, "position_mm": [x_mm, y_mm, z_mm], "orientation_deg": {"roll": roll_deg, "pitch": pitch_deg, "yaw": yaw_deg}})
|
||||||
|
|
||||||
|
# Save CSV
|
||||||
|
try:
|
||||||
|
with open(OUTPUT_CSV_PATH, 'w', newline='') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"\n[INFO] Saved CSV poses to '{OUTPUT_CSV_PATH}'.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Could not save CSV: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Save JSON
|
||||||
|
json_data = {
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"reference_markers": common_ids,
|
||||||
|
"rms_residual_mm": rms,
|
||||||
|
"description": "Multi-marker machine frame fit with camera pose"
|
||||||
|
},
|
||||||
|
"camera": camera_pose,
|
||||||
|
"markers": marker_list
|
||||||
|
}
|
||||||
|
with open(OUTPUT_JSON_PATH, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(json_data, f, indent=4)
|
||||||
|
|
||||||
|
# Warn about expected IDs
|
||||||
|
detected_ids = set(poses_cam.keys())
|
||||||
|
missing = EXPECTED_IDS - detected_ids
|
||||||
|
if missing:
|
||||||
|
print(f"[WARN] Expected markers not detected: {sorted(missing)}")
|
||||||
|
|
||||||
|
# Draw machine axes using global transform (machine->camera)
|
||||||
|
R_machine_to_cam = R_cam_to_machine.T
|
||||||
|
t_machine_to_cam = - R_machine_to_cam @ t_cam_to_machine
|
||||||
|
try:
|
||||||
|
machine_axes = np.float32([
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[MACHINE_AXIS_X_M, 0.0, 0.0],
|
||||||
|
[0.0, MACHINE_AXIS_Y_M, 0.0],
|
||||||
|
[0.0, 0.0, MACHINE_AXIS_Z_M],
|
||||||
|
])
|
||||||
|
rvec_global, _ = cv2.Rodrigues(R_machine_to_cam)
|
||||||
|
imgpts, _ = cv2.projectPoints(machine_axes, rvec_global, t_machine_to_cam, camera_matrix, dist_coeffs)
|
||||||
|
origin = tuple(np.round(imgpts[0].ravel()).astype(int))
|
||||||
|
x_end = tuple(np.round(imgpts[1].ravel()).astype(int))
|
||||||
|
y_end = tuple(np.round(imgpts[2].ravel()).astype(int))
|
||||||
|
z_end = tuple(np.round(imgpts[3].ravel()).astype(int))
|
||||||
|
cv2.line(draw_img, origin, x_end, (0, 0, 255), 3)
|
||||||
|
cv2.line(draw_img, origin, y_end, (0, 255, 0), 3)
|
||||||
|
cv2.line(draw_img, origin, z_end, (255, 0, 0), 3)
|
||||||
|
cv2.putText(draw_img, "X (200 mm)", x_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,255), 2)
|
||||||
|
cv2.putText(draw_img, "Y (-100 mm)", y_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2)
|
||||||
|
cv2.putText(draw_img, "+Z (100 mm)", z_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,0,0), 2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to draw machine axes: {e}")
|
||||||
|
|
||||||
|
ok = cv2.imwrite(OUTPUT_IMAGE_PATH, draw_img)
|
||||||
|
if ok:
|
||||||
|
print(f"[INFO] Annotated image saved as '{OUTPUT_IMAGE_PATH}'.")
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Failed to save annotated image '{OUTPUT_IMAGE_PATH}'.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
668
programs/readTwoImages.py
Executable file
668
programs/readTwoImages.py
Executable file
@@ -0,0 +1,668 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
readCamPositionTwo.py
|
||||||
|
|
||||||
|
Two-camera ArUco detection with joint optimization of both camera extrinsics
|
||||||
|
against known machine-frame reference markers, plus triangulation of unknown
|
||||||
|
marker positions. Outputs camera pose and marker poses in machine coordinates,
|
||||||
|
with CSV and JSON similar to the single-camera script.
|
||||||
|
|
||||||
|
Dependencies: numpy, opencv-python (cv2)
|
||||||
|
Optional but NOT required: SciPy (we implement a simple Levenberg–Marquardt).
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
python3 readTwoImages.py -i snapshot_video0_1764531874081.jpg -i snapshot_video1_1764531874081.jpg -npz callibration_cam0.npz -npz callibration_cam1.npz -settings settings.json
|
||||||
|
python3 readTwoImages.py -i snapshot_video0_1764524369655.jpg -i snapshot_video1_1764524369655.jpg -npz callibration_cam0.npz -npz callibration_cam1.npz -settings settings.json
|
||||||
|
|
||||||
|
python3 readTwoImages.py -i snapshot_video0_1765009029764.jpg -i snapshot_video1_1765009029764.jpg -npz callibration_cam0.npz -npz callibration_cam1.npz -settings settings.json
|
||||||
|
|
||||||
|
Settings JSON is expected to contain:
|
||||||
|
{
|
||||||
|
"coordinateSystem": {
|
||||||
|
"KnownMarkers": {
|
||||||
|
"50": [0.0, 0.0, 0.0],
|
||||||
|
"71": [0.140, 0.0, 0.0],
|
||||||
|
"101": [0.0, -0.080, 0.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Author: M365 Copilot (generated)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Dict, Tuple, List
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# ---------------- Configuration defaults ----------------
|
||||||
|
AXIS_LENGTH_M = 0.05
|
||||||
|
|
||||||
|
# ---------------- Utilities ----------------
|
||||||
|
def load_intrinsics_npz(npz_path: str) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
print("NPZ reading of file:", npz_path)
|
||||||
|
if os.path.exists(npz_path):
|
||||||
|
data = np.load(npz_path)
|
||||||
|
for k in ('camera_matrix', 'mtx', 'K'):
|
||||||
|
if k in data:
|
||||||
|
camera_matrix = data[k].astype(np.float32)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise KeyError("Camera matrix not found in NPZ.")
|
||||||
|
for k in ('dist_coeffs', 'dist', 'D'):
|
||||||
|
if k in data:
|
||||||
|
dist = data[k].astype(np.float32).reshape(-1,1)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
dist = np.zeros((5,1), dtype=np.float32)
|
||||||
|
print("NPZ loaded:", npz_path)
|
||||||
|
return camera_matrix, dist
|
||||||
|
# Fallback default intrinsics
|
||||||
|
camera_matrix = np.array([[1400, 0, 640],
|
||||||
|
[0, 1400, 360],
|
||||||
|
[0, 0, 1]], dtype=np.float32)
|
||||||
|
dist_coeffs = np.zeros((5,1), dtype=np.float32)
|
||||||
|
print("[WARN] Using default approximate intrinsics.")
|
||||||
|
return camera_matrix, dist_coeffs
|
||||||
|
|
||||||
|
|
||||||
|
def get_aruco_detector(dict_name: str):
|
||||||
|
mapping = {
|
||||||
|
'DICT_4X4_250': cv2.aruco.DICT_4X4_250,
|
||||||
|
'DICT_5X5_100': cv2.aruco.DICT_5X5_100,
|
||||||
|
'DICT_6X6_250': cv2.aruco.DICT_6X6_250,
|
||||||
|
'DICT_ARUCO_ORIGINAL': cv2.aruco.DICT_ARUCO_ORIGINAL,
|
||||||
|
}
|
||||||
|
if dict_name not in mapping:
|
||||||
|
dict_id = cv2.aruco.DICT_4X4_250
|
||||||
|
else:
|
||||||
|
dict_id = mapping[dict_name]
|
||||||
|
dictionary = cv2.aruco.getPredefinedDictionary(dict_id)
|
||||||
|
try:
|
||||||
|
params = cv2.aruco.DetectorParameters()
|
||||||
|
except Exception:
|
||||||
|
params = cv2.aruco.DetectorParameters_create()
|
||||||
|
try:
|
||||||
|
detector = cv2.aruco.ArucoDetector(dictionary, params)
|
||||||
|
return detector, None
|
||||||
|
except Exception:
|
||||||
|
return None, (dictionary, params)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_markers(image: np.ndarray, detector_tuple):
|
||||||
|
detector, fallback = detector_tuple
|
||||||
|
if detector is not None:
|
||||||
|
corners, ids, rejected = detector.detectMarkers(image)
|
||||||
|
else:
|
||||||
|
dictionary, params = fallback
|
||||||
|
corners, ids, rejected = cv2.aruco.detectMarkers(image, dictionary, parameters=params)
|
||||||
|
return corners, ids, rejected
|
||||||
|
|
||||||
|
|
||||||
|
def corners_to_image_points(corners: np.ndarray) -> np.ndarray:
|
||||||
|
return corners.reshape(4,2).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def marker_center_from_corners(corners: np.ndarray) -> np.ndarray:
|
||||||
|
pts = corners.reshape(4,2)
|
||||||
|
return pts.mean(axis=0).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def rvec_to_R(rvec: np.ndarray) -> np.ndarray:
|
||||||
|
R, _ = cv2.Rodrigues(rvec)
|
||||||
|
return R
|
||||||
|
|
||||||
|
|
||||||
|
def rigid_transform_no_scale(A: np.ndarray, B: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Find R,t s.t. B ≈ R A + t. A,B: Nx3."""
|
||||||
|
assert A.shape == B.shape and A.shape[1] == 3, "A and B must be Nx3"
|
||||||
|
N = A.shape[0]
|
||||||
|
if N < 2:
|
||||||
|
raise ValueError("Need at least 2 points; 3+ recommended.")
|
||||||
|
centroid_A = A.mean(axis=0)
|
||||||
|
centroid_B = B.mean(axis=0)
|
||||||
|
AA = A - centroid_A
|
||||||
|
BB = B - centroid_B
|
||||||
|
H = AA.T @ BB
|
||||||
|
U, S, Vt = np.linalg.svd(H)
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
if np.linalg.det(R) < 0:
|
||||||
|
Vt[-1, :] *= -1
|
||||||
|
R = Vt.T @ U.T
|
||||||
|
t = centroid_B - R @ centroid_A
|
||||||
|
return R.astype(np.float32), t.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def undistort_to_normalized(points_px: np.ndarray, K: np.ndarray, D: np.ndarray) -> np.ndarray:
|
||||||
|
"""points_px: Nx2 pixel. Returns Nx2 normalized coords (x,y) where projection is x=Xp/Z, y=Yp/Z.
|
||||||
|
cv2.undistortPoints with P=None yields normalized coordinates.
|
||||||
|
"""
|
||||||
|
pts = points_px.reshape(-1,1,2).astype(np.float32)
|
||||||
|
und = cv2.undistortPoints(pts, K, D, P=None) # returns Nx1x2
|
||||||
|
return und.reshape(-1,2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Joint optimization (LM, numerical Jacobian) ----------------
|
||||||
|
|
||||||
|
def pack_params(omega1, t1, omega2, t2) -> np.ndarray:
|
||||||
|
return np.hstack([omega1, t1, omega2, t2]).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_params(theta: np.ndarray):
|
||||||
|
omega1 = theta[0:3]
|
||||||
|
t1 = theta[3:6]
|
||||||
|
omega2 = theta[6:9]
|
||||||
|
t2 = theta[9:12]
|
||||||
|
return omega1, t1, omega2, t2
|
||||||
|
|
||||||
|
|
||||||
|
def residuals_centers_normalized(theta: np.ndarray,
|
||||||
|
X_mach: np.ndarray,
|
||||||
|
obs1_norm: np.ndarray,
|
||||||
|
obs2_norm: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Compute residuals in normalized coordinates (no intrinsics, no distortion).
|
||||||
|
For camera j: X_cam = R_j X_mach + t_j; proj: (x/z, y/z).
|
||||||
|
Returns stacked residuals [r1; r2] shape (4N,), where N = number of references.
|
||||||
|
"""
|
||||||
|
omega1, t1, omega2, t2 = unpack_params(theta)
|
||||||
|
R1 = cv2.Rodrigues(omega1)[0]
|
||||||
|
R2 = cv2.Rodrigues(omega2)[0]
|
||||||
|
# Camera 1 projections
|
||||||
|
X_cam1 = (R1 @ X_mach.T + t1.reshape(3,1)).T # Nx3
|
||||||
|
uv1 = X_cam1[:, :2] / X_cam1[:, 2:3]
|
||||||
|
r1 = (obs1_norm - uv1).reshape(-1)
|
||||||
|
# Camera 2 projections
|
||||||
|
X_cam2 = (R2 @ X_mach.T + t2.reshape(3,1)).T
|
||||||
|
uv2 = X_cam2[:, :2] / X_cam2[:, 2:3]
|
||||||
|
r2 = (obs2_norm - uv2).reshape(-1)
|
||||||
|
return np.hstack([r1, r2])
|
||||||
|
|
||||||
|
|
||||||
|
def numerical_jacobian(f, theta: np.ndarray, eps: float, *args) -> np.ndarray:
|
||||||
|
"""Finite-difference Jacobian: forward differences."""
|
||||||
|
r0 = f(theta, *args)
|
||||||
|
m = r0.size
|
||||||
|
n = theta.size
|
||||||
|
J = np.zeros((m, n), dtype=np.float64)
|
||||||
|
for k in range(n):
|
||||||
|
th = theta.copy()
|
||||||
|
th[k] += eps
|
||||||
|
rk = f(th, *args)
|
||||||
|
J[:, k] = (rk - r0) / eps
|
||||||
|
return J, r0
|
||||||
|
|
||||||
|
|
||||||
|
def lm_solve(theta0: np.ndarray,
|
||||||
|
X_mach: np.ndarray,
|
||||||
|
obs1_norm: np.ndarray,
|
||||||
|
obs2_norm: np.ndarray,
|
||||||
|
max_iter: int = 50,
|
||||||
|
eps_jac: float = 1e-6,
|
||||||
|
lambda_init: float = 1e-3) -> Tuple[np.ndarray, Dict]:
|
||||||
|
"""Simple Levenberg–Marquardt on center normalized residuals."""
|
||||||
|
lam = lambda_init
|
||||||
|
theta = theta0.copy()
|
||||||
|
history = {"iters": [], "rms": []}
|
||||||
|
for it in range(max_iter):
|
||||||
|
J, r = numerical_jacobian(residuals_centers_normalized, theta, eps_jac,
|
||||||
|
X_mach, obs1_norm, obs2_norm)
|
||||||
|
rms = float(np.sqrt(np.mean(r*r))) if r.size else 0.0
|
||||||
|
history["iters"].append(it)
|
||||||
|
history["rms"].append(rms)
|
||||||
|
# Normal equations with damping
|
||||||
|
JTJ = J.T @ J
|
||||||
|
g = J.T @ r
|
||||||
|
H = JTJ + lam * np.eye(JTJ.shape[0])
|
||||||
|
try:
|
||||||
|
delta = -np.linalg.solve(H, g)
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
# Fallback to least squares
|
||||||
|
delta, *_ = np.linalg.lstsq(H, -g, rcond=None)
|
||||||
|
theta_trial = theta + delta
|
||||||
|
r_trial = residuals_centers_normalized(theta_trial, X_mach, obs1_norm, obs2_norm)
|
||||||
|
rms_trial = float(np.sqrt(np.mean(r_trial*r_trial)))
|
||||||
|
if rms_trial < rms: # improve
|
||||||
|
theta = theta_trial
|
||||||
|
lam *= 0.5
|
||||||
|
else:
|
||||||
|
lam *= 2.0
|
||||||
|
# Convergence criteria
|
||||||
|
if np.linalg.norm(delta) < 1e-9 or abs(rms - rms_trial) < 1e-9:
|
||||||
|
break
|
||||||
|
return theta, history
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Triangulation ----------------
|
||||||
|
|
||||||
|
def build_projection_matrix(K: np.ndarray, R: np.ndarray, t: np.ndarray) -> np.ndarray:
|
||||||
|
return K @ np.hstack([R, t.reshape(3,1)])
|
||||||
|
|
||||||
|
|
||||||
|
def triangulate_center(P1: np.ndarray, P2: np.ndarray, u1: np.ndarray, u2: np.ndarray) -> np.ndarray:
|
||||||
|
# u1,u2: (2,) pixel coordinates
|
||||||
|
x1 = u1.reshape(2,1)
|
||||||
|
x2 = u2.reshape(2,1)
|
||||||
|
X_h = cv2.triangulatePoints(P1, P2, x1, x2) # 4x1 homogeneous in machine frame if P maps machine->pixels
|
||||||
|
X = (X_h[:3] / X_h[3]).reshape(3)
|
||||||
|
return X.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Main ----------------
|
||||||
|
def main():
|
||||||
|
print("Started")
|
||||||
|
parser = argparse.ArgumentParser(description="Two-camera ArUco joint optimization and triangulation")
|
||||||
|
parser.add_argument('-i', '--images', action='append', required=True,
|
||||||
|
help="Two image paths: first camera then second camera")
|
||||||
|
parser.add_argument('-npz', '--npz', action='append', required=True,
|
||||||
|
help="Two NPZ intrinsics paths: cam1 then cam2")
|
||||||
|
parser.add_argument('-markerSizeMM', '--markerSizeMM', type=float, default=25.0,
|
||||||
|
help="Marker side length in millimeters")
|
||||||
|
parser.add_argument('--dict', default='DICT_4X4_250', help="ArUco dictionary name")
|
||||||
|
parser.add_argument('-settings', type=str, default=None,
|
||||||
|
help="Json settings file containing machine KnownMarkers")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if len(args.images) != 2 or len(args.npz) != 2:
|
||||||
|
print("[ERROR] Provide exactly two images and two intrinsics NPZ files.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
img1 = cv2.imread(args.images[0])
|
||||||
|
img2 = cv2.imread(args.images[1])
|
||||||
|
draw1 = img1.copy()
|
||||||
|
draw2 = img2.copy()
|
||||||
|
h, w = draw1.shape[:2]
|
||||||
|
#drawPNG1 = np.zeros((h, w, 4), dtype=np.uint8) # fully transparent
|
||||||
|
drawPNG1 = np.zeros((h, w, 3), dtype=np.uint8)
|
||||||
|
# Also prepare a matching canvas for camera2 to keep the layout tidy
|
||||||
|
drawPNG2 = np.zeros((h, w, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
if img1 is None or img2 is None:
|
||||||
|
print("[ERROR] Cannot read one of the images.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
K1, D1 = load_intrinsics_npz(args.npz[0])
|
||||||
|
K2, D2 = load_intrinsics_npz(args.npz[1])
|
||||||
|
|
||||||
|
# Marker 3D local geometry (square corners)
|
||||||
|
half = (args.markerSizeMM / 1000.0) / 2.0
|
||||||
|
obj_points = np.array([
|
||||||
|
[-half, half, 0.0],
|
||||||
|
[ half, half, 0.0],
|
||||||
|
[ half, -half, 0.0],
|
||||||
|
[-half, -half, 0.0],
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
# Read settings for machine known markers
|
||||||
|
known_markers: Dict[int, np.ndarray] = {}
|
||||||
|
if args.settings is not None and os.path.exists(args.settings):
|
||||||
|
with open(args.settings, 'r') as f:
|
||||||
|
settings = json.load(f)
|
||||||
|
for marker_id, coords in settings['coordinateSystem']['KnownMarkers'].items():
|
||||||
|
known_markers[int(marker_id)] = np.array(coords, dtype=np.float32)
|
||||||
|
print("[INFO] Loaded KnownMarkers from settings.")
|
||||||
|
else:
|
||||||
|
# Fallback defaults
|
||||||
|
known_markers = {
|
||||||
|
50: np.array([0.0, 0.0, 0.0], dtype=np.float32),
|
||||||
|
71: np.array([0.140, 0.0, 0.0], dtype=np.float32),
|
||||||
|
101: np.array([0.0, -0.080, 0.0], dtype=np.float32),
|
||||||
|
}
|
||||||
|
print("[WARN] Using default KnownMarkers; provide -settings for your machine.")
|
||||||
|
|
||||||
|
# Detect markers in both images
|
||||||
|
detector_tuple = get_aruco_detector(args.dict)
|
||||||
|
corners1, ids1, _ = detect_markers(img1, detector_tuple)
|
||||||
|
corners2, ids2, _ = detect_markers(img2, detector_tuple)
|
||||||
|
if ids1 is None or ids2 is None:
|
||||||
|
print("[ERROR] No markers detected in one or both images.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ids1 = ids1.flatten().tolist()
|
||||||
|
ids2 = ids2.flatten().tolist()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Build dicts: id -> corners, center, rvec/tvec (per-camera PnP)
|
||||||
|
def build_marker_dict(img, corners_list, ids, K, D, draw = False) -> Tuple[Dict[int,np.ndarray], Dict[int,np.ndarray], Dict[int,Tuple[np.ndarray,np.ndarray]]]:
|
||||||
|
centers = {}
|
||||||
|
corners_dict = {}
|
||||||
|
poses = {}
|
||||||
|
for i, mid in enumerate(ids):
|
||||||
|
C = corners_list[i]
|
||||||
|
corners_dict[int(mid)] = C
|
||||||
|
centers[int(mid)] = marker_center_from_corners(C)
|
||||||
|
# Pose from single marker
|
||||||
|
img_pts = corners_to_image_points(C)
|
||||||
|
success, rvec, tvec = cv2.solvePnP(obj_points, img_pts, K, D, flags=cv2.SOLVEPNP_IPPE_SQUARE)
|
||||||
|
if not success:
|
||||||
|
success, rvec, tvec = cv2.solvePnP(obj_points, img_pts, K, D)
|
||||||
|
if success:
|
||||||
|
poses[int(mid)] = (rvec.reshape(3,1), tvec.reshape(3,1))
|
||||||
|
if(draw):
|
||||||
|
cv2.drawFrameAxes(draw1, K, D, rvec, tvec, 0.02) # slim orientation axes
|
||||||
|
cv2.drawFrameAxes(drawPNG1, K, D, rvec, tvec, 0.02) # slim orientation axes
|
||||||
|
return centers, corners_dict, poses
|
||||||
|
|
||||||
|
centers1, corners_dict1, poses1 = build_marker_dict(img1, corners1, ids1, K1, D1, draw = True)
|
||||||
|
centers2, corners_dict2, poses2 = build_marker_dict(img2, corners2, ids2, K2, D2)
|
||||||
|
|
||||||
|
# Common reference markers present in both images and known in machine frame
|
||||||
|
common_refs = [mid for mid in known_markers.keys() if (mid in centers1 and mid in centers2)]
|
||||||
|
if len(common_refs) < 3:
|
||||||
|
print(f"[ERROR] Need ≥3 common reference markers in both cameras; found {len(common_refs)}: {common_refs}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initial extrinsics from rigid fits per camera using tvec centers of references
|
||||||
|
# For camera j, A = camera-frame positions of reference markers (from PnP tvec), B = machine positions
|
||||||
|
def init_extrinsics_from_rigid(poses_cam: Dict[int,Tuple[np.ndarray,np.ndarray]], refs: List[int]) -> Tuple[np.ndarray,np.ndarray]:
|
||||||
|
A = []
|
||||||
|
B = []
|
||||||
|
for mid in refs:
|
||||||
|
if mid in poses_cam:
|
||||||
|
_, tvec = poses_cam[mid]
|
||||||
|
A.append(tvec.flatten())
|
||||||
|
B.append(known_markers[mid])
|
||||||
|
if len(A) < 2:
|
||||||
|
raise RuntimeError("Insufficient reference poses for rigid transform init.")
|
||||||
|
A = np.stack(A, axis=0)
|
||||||
|
B = np.stack(B, axis=0)
|
||||||
|
R_cm, t_cm = rigid_transform_no_scale(A, B) # camera->machine
|
||||||
|
# Convert to machine->camera
|
||||||
|
R_mc = R_cm.T
|
||||||
|
t_mc = - R_mc @ t_cm
|
||||||
|
return R_mc.astype(np.float32), t_mc.astype(np.float32)
|
||||||
|
|
||||||
|
R1_init, t1_init = init_extrinsics_from_rigid(poses1, common_refs)
|
||||||
|
R2_init, t2_init = init_extrinsics_from_rigid(poses2, common_refs)
|
||||||
|
|
||||||
|
# Observations: reference centers (pixels) -> normalized
|
||||||
|
X_mach_refs = np.stack([known_markers[mid] for mid in common_refs], axis=0).astype(np.float32)
|
||||||
|
obs1_px = np.stack([centers1[mid] for mid in common_refs], axis=0).astype(np.float32)
|
||||||
|
obs2_px = np.stack([centers2[mid] for mid in common_refs], axis=0).astype(np.float32)
|
||||||
|
obs1_norm = undistort_to_normalized(obs1_px, K1, D1)
|
||||||
|
obs2_norm = undistort_to_normalized(obs2_px, K2, D2)
|
||||||
|
|
||||||
|
# Pack initial params as Rodrigues vectors
|
||||||
|
omega1_init = cv2.Rodrigues(R1_init)[0].reshape(3)
|
||||||
|
omega2_init = cv2.Rodrigues(R2_init)[0].reshape(3)
|
||||||
|
theta0 = pack_params(omega1_init, t1_init.reshape(3), omega2_init, t2_init.reshape(3))
|
||||||
|
|
||||||
|
theta_opt, hist = lm_solve(theta0, X_mach_refs, obs1_norm, obs2_norm,
|
||||||
|
max_iter=60, eps_jac=1e-6, lambda_init=1e-3)
|
||||||
|
|
||||||
|
omega1, t1, omega2, t2 = unpack_params(theta_opt)
|
||||||
|
R1_opt = cv2.Rodrigues(omega1)[0]
|
||||||
|
R2_opt = cv2.Rodrigues(omega2)[0]
|
||||||
|
|
||||||
|
# Camera pose in machine coordinates (camera->machine): R_cm = R^T, t_cm = -R^T t
|
||||||
|
R1_cm = R1_opt.T
|
||||||
|
t1_cm = - R1_cm @ t1
|
||||||
|
R2_cm = R2_opt.T
|
||||||
|
t2_cm = - R2_cm @ t2
|
||||||
|
|
||||||
|
# Build projection matrices for triangulation (machine -> pixels)
|
||||||
|
P1 = build_projection_matrix(K1, R1_opt, t1)
|
||||||
|
P2 = build_projection_matrix(K2, R2_opt, t2)
|
||||||
|
|
||||||
|
# Collect markers seen by at least one camera
|
||||||
|
all_ids = set(ids1) | set(ids2)
|
||||||
|
# Output structures
|
||||||
|
rows = [("id", "x_mm", "y_mm", "z_mm", "roll_deg", "pitch_deg", "yaw_deg")]
|
||||||
|
marker_list = []
|
||||||
|
|
||||||
|
# Camera orientations in Euler (ZYX)
|
||||||
|
def R_to_euler_zyx(R: np.ndarray) -> Tuple[float,float,float]:
|
||||||
|
yaw = float(np.degrees(np.arctan2(R[1,0], R[0,0])))
|
||||||
|
sp = np.sqrt(R[2,1]**2 + R[2,2]**2)
|
||||||
|
pitch = float(np.degrees(np.arctan2(-R[2,0], sp)))
|
||||||
|
roll = float(np.degrees(np.arctan2(R[2,1], R[2,2])))
|
||||||
|
return roll, pitch, yaw
|
||||||
|
|
||||||
|
cam1_roll, cam1_pitch, cam1_yaw = R_to_euler_zyx(R1_cm)
|
||||||
|
cam2_roll, cam2_pitch, cam2_yaw = R_to_euler_zyx(R2_cm)
|
||||||
|
|
||||||
|
# Camera rows
|
||||||
|
c1_mm = (t1_cm * 1000.0).tolist()
|
||||||
|
rows.append(("camera 0", f"{c1_mm[0]:.2f}", f"{c1_mm[1]:.2f}", f"{c1_mm[2]:.2f}", f"{cam1_roll:.3f}", f"{cam1_pitch:.3f}", f"{cam1_yaw:.3f}"))
|
||||||
|
c2_mm = (t2_cm * 1000.0).tolist()
|
||||||
|
rows.append(("camera 1", f"{c2_mm[0]:.2f}", f"{c2_mm[1]:.2f}", f"{c2_mm[2]:.2f}", f"{cam2_roll:.3f}", f"{cam2_pitch:.3f}", f"{cam2_yaw:.3f}"))
|
||||||
|
|
||||||
|
# Triangulate and output markers
|
||||||
|
def orientation_in_machine(mid: int) -> Tuple[float,float,float]:
|
||||||
|
# Prefer camera1 orientation, else camera2
|
||||||
|
if mid in poses1:
|
||||||
|
Rm_cam = rvec_to_R(poses1[mid][0])
|
||||||
|
Rm_machine = R1_cm @ Rm_cam
|
||||||
|
elif mid in poses2:
|
||||||
|
Rm_cam = rvec_to_R(poses2[mid][0])
|
||||||
|
Rm_machine = R2_cm @ Rm_cam
|
||||||
|
else:
|
||||||
|
Rm_machine = np.eye(3, dtype=np.float32)
|
||||||
|
return R_to_euler_zyx(Rm_machine)
|
||||||
|
|
||||||
|
# Residual report for references
|
||||||
|
# Recompute residual RMS in pixels for references (after optimization)
|
||||||
|
refs_rms_px = []
|
||||||
|
for j,(K,R_opt,t_opt,centers_px) in enumerate([(K1,R1_opt,t1,centers1),(K2,R2_opt,t2,centers2)]):
|
||||||
|
errs = []
|
||||||
|
for mid in common_refs:
|
||||||
|
X = known_markers[mid]
|
||||||
|
X_cam = R_opt @ X + t_opt
|
||||||
|
x = K @ X_cam
|
||||||
|
u_pred = x[0]/x[2]
|
||||||
|
v_pred = x[1]/x[2]
|
||||||
|
u_obs, v_obs = centers_px[mid]
|
||||||
|
errs.append(np.hypot(u_obs-u_pred, v_obs-v_pred))
|
||||||
|
refs_rms_px.append(float(np.sqrt(np.mean(np.square(errs))) if errs else 0.0))
|
||||||
|
|
||||||
|
# Compute per-marker positions
|
||||||
|
for mid in sorted(all_ids):
|
||||||
|
# Triangulate if seen in both
|
||||||
|
if mid in centers1 and mid in centers2:
|
||||||
|
X_mach = triangulate_center(P1, P2, centers1[mid], centers2[mid])
|
||||||
|
elif mid in poses1:
|
||||||
|
# Fallback single-camera: transform tvec (camera->machine)
|
||||||
|
X_mach = (R1_cm @ poses1[mid][1].flatten() + t1_cm)
|
||||||
|
elif mid in poses2:
|
||||||
|
X_mach = (R2_cm @ poses2[mid][1].flatten() + t2_cm)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
roll, pitch, yaw = orientation_in_machine(mid)
|
||||||
|
x_mm, y_mm, z_mm = (X_mach * 1000.0).tolist()
|
||||||
|
rows.append((mid, f"{x_mm:.2f}", f"{y_mm:.2f}", f"{z_mm:.2f}", f"{roll:.3f}", f"{pitch:.3f}", f"{yaw:.3f}"))
|
||||||
|
marker_list.append({
|
||||||
|
"id": int(mid),
|
||||||
|
"position_mm": [float(x_mm), float(y_mm), float(z_mm)],
|
||||||
|
"orientation_deg": {"roll": float(roll), "pitch": float(pitch), "yaw": float(yaw)}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save CSV & JSON
|
||||||
|
base1 = os.path.splitext(args.images[0])[0]
|
||||||
|
base2 = os.path.splitext(args.images[1])[0]
|
||||||
|
out_csv = f"{base1}_two_cam.csv"
|
||||||
|
out_json = f"{base1}_two_cam.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import csv
|
||||||
|
with open(out_csv, 'w', newline='') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"[INFO] Saved CSV poses to '{out_csv}'.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Could not save CSV: {e}")
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"reference_markers": common_refs,
|
||||||
|
"dict": args.dict,
|
||||||
|
"marker_size_mm": args.markerSizeMM,
|
||||||
|
"rms_refs_px_cam1": refs_rms_px[0] if refs_rms_px else None,
|
||||||
|
"rms_refs_px_cam2": refs_rms_px[1] if refs_rms_px else None,
|
||||||
|
"description": "Two-camera joint optimization with triangulation"
|
||||||
|
},
|
||||||
|
"cameras": [
|
||||||
|
{
|
||||||
|
"id": "camera1",
|
||||||
|
"position_mm": [float(x) for x in (t1_cm * 1000.0)],
|
||||||
|
"orientation_deg": {"roll": cam1_roll, "pitch": cam1_pitch, "yaw": cam1_yaw},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camera2",
|
||||||
|
"position_mm": [float(x) for x in (t2_cm * 1000.0)],
|
||||||
|
"orientation_deg": {"roll": cam2_roll, "pitch": cam2_pitch, "yaw": cam2_yaw},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": marker_list
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(out_json, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(json_data, f, indent=4)
|
||||||
|
print(f"[INFO] Saved JSON poses to '{out_json}'.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Could not save JSON: {e}")
|
||||||
|
|
||||||
|
# Annotate images with machine axes using camera1 extrinsics
|
||||||
|
try:
|
||||||
|
R_machine_to_cam1 = R1_opt
|
||||||
|
t_machine_to_cam1 = t1
|
||||||
|
machine_axes = np.float32([
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[0.200, 0.0, 0.0],
|
||||||
|
[0.0, -0.100, 0.0],
|
||||||
|
[0.0, 0.0, 0.100],
|
||||||
|
])
|
||||||
|
rvec_global, _ = cv2.Rodrigues(R_machine_to_cam1)
|
||||||
|
imgpts, _ = cv2.projectPoints(machine_axes, rvec_global, t_machine_to_cam1, K1, D1)
|
||||||
|
origin = tuple(np.round(imgpts[0].ravel()).astype(int))
|
||||||
|
x_end = tuple(np.round(imgpts[1].ravel()).astype(int))
|
||||||
|
y_end = tuple(np.round(imgpts[2].ravel()).astype(int))
|
||||||
|
z_end = tuple(np.round(imgpts[3].ravel()).astype(int))
|
||||||
|
|
||||||
|
# Draw marker outlines only (omit default small id labels) — we draw larger IDs below
|
||||||
|
cv2.aruco.drawDetectedMarkers(draw1, corners1)
|
||||||
|
cv2.aruco.drawDetectedMarkers(drawPNG1, corners1)
|
||||||
|
# Draw larger blue ID labels (keep default marker outlines as-is)
|
||||||
|
try:
|
||||||
|
for i, mid in enumerate(ids1):
|
||||||
|
try:
|
||||||
|
pts = corners1[i].reshape((4, 2))
|
||||||
|
center = tuple(np.round(pts.mean(axis=0)).astype(int))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
text = str(int(mid))
|
||||||
|
# Offset: 5px more to the right and 5px up (y axis is downwards)
|
||||||
|
pos = (int(center[0]) + 15, int(center[1]) - 15)
|
||||||
|
cv2.putText(draw1, text, pos, cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,0,0), 3, lineType=cv2.LINE_AA)
|
||||||
|
cv2.putText(drawPNG1, text, pos, cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,0,0,255), 3, lineType=cv2.LINE_AA)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cv2.line(draw1, origin, x_end, (0,0,255), 3)
|
||||||
|
cv2.line(draw1, origin, y_end, (0,255,0), 3)
|
||||||
|
cv2.line(draw1, origin, z_end, (255,0,0), 3)
|
||||||
|
|
||||||
|
# Draw lines (RGBA colors: B,G,R,A). A=255 for fully opaque.
|
||||||
|
cv2.line(drawPNG1, origin, x_end, (0, 0, 255, 255), 3) # Red X
|
||||||
|
cv2.line(drawPNG1, origin, y_end, (0, 255, 0, 255), 3) # Green Y
|
||||||
|
cv2.line(drawPNG1, origin, z_end, (255, 0, 0, 255), 3) # Blue Z
|
||||||
|
|
||||||
|
cv2.putText(draw1, "X (200 mm)", x_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,255), 2)
|
||||||
|
cv2.putText(draw1, "Y (-100 mm)", y_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2)
|
||||||
|
cv2.putText(draw1, "+Z (100 mm)", z_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,0,0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
# Try to draw text (might be jaggy on transparent BG in older OpenCV)
|
||||||
|
cv2.putText(drawPNG1, "X (200 mm)", x_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255, 255), 2)
|
||||||
|
cv2.putText(drawPNG1, "Y (-100 mm)", y_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0, 255), 2)
|
||||||
|
cv2.putText(drawPNG1, "+Z (100 mm)", z_end, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0, 255), 2)
|
||||||
|
|
||||||
|
|
||||||
|
out_img1 = f"{base1}_two_cam_annotated.jpg"
|
||||||
|
cv2.imwrite(out_img1, draw1)
|
||||||
|
print(f"[INFO] Annotated image saved as '{out_img1}'.")
|
||||||
|
|
||||||
|
# Save as transparent PNG
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(drawPNG1, cv2.COLOR_BGR2GRAY)
|
||||||
|
_, alpha = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY)
|
||||||
|
|
||||||
|
# 5) Merge BGR + alpha → RGBA transparent overlay
|
||||||
|
drawPNG_1 = cv2.merge([drawPNG1[:, :, 0], drawPNG1[:, :, 1], drawPNG1[:, :, 2], alpha])
|
||||||
|
|
||||||
|
out_png1 = f"{base1}_two_cam_overlay.png"
|
||||||
|
cv2.imwrite(out_png1, drawPNG_1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to draw machine axes: {e}")
|
||||||
|
|
||||||
|
# Also annotate the second camera image and produce a transparent overlay
|
||||||
|
try:
|
||||||
|
machine_axes2 = np.float32([
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[0.200, 0.0, 0.0],
|
||||||
|
[0.0, -0.100, 0.0],
|
||||||
|
[0.0, 0.0, 0.100],
|
||||||
|
])
|
||||||
|
rvec_global2, _ = cv2.Rodrigues(R2_opt)
|
||||||
|
imgpts2, _ = cv2.projectPoints(machine_axes2, rvec_global2, t2, K2, D2)
|
||||||
|
origin2 = tuple(np.round(imgpts2[0].ravel()).astype(int))
|
||||||
|
x_end2 = tuple(np.round(imgpts2[1].ravel()).astype(int))
|
||||||
|
y_end2 = tuple(np.round(imgpts2[2].ravel()).astype(int))
|
||||||
|
z_end2 = tuple(np.round(imgpts2[3].ravel()).astype(int))
|
||||||
|
|
||||||
|
# Draw marker outlines only (omit default small id labels) — we draw larger IDs below
|
||||||
|
cv2.aruco.drawDetectedMarkers(draw2, corners2)
|
||||||
|
cv2.aruco.drawDetectedMarkers(drawPNG2, corners2)
|
||||||
|
# Draw larger blue ID labels (keep default marker outlines as-is)
|
||||||
|
try:
|
||||||
|
for i, mid in enumerate(ids2):
|
||||||
|
try:
|
||||||
|
pts = corners2[i].reshape((4, 2))
|
||||||
|
center = tuple(np.round(pts.mean(axis=0)).astype(int))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
text = str(int(mid))
|
||||||
|
# Offset: 5px more to the right and 5px up (y axis is downwards)
|
||||||
|
pos = (int(center[0]) + 13, int(center[1]) + 3)
|
||||||
|
cv2.putText(draw2, text, pos, cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,0,0), 3, lineType=cv2.LINE_AA)
|
||||||
|
cv2.putText(drawPNG2, text, pos, cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,0,0,255), 3, lineType=cv2.LINE_AA)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cv2.line(draw2, origin2, x_end2, (0,0,255), 3)
|
||||||
|
cv2.line(draw2, origin2, y_end2, (0,255,0), 3)
|
||||||
|
cv2.line(draw2, origin2, z_end2, (255,0,0), 3)
|
||||||
|
|
||||||
|
cv2.line(drawPNG2, origin2, x_end2, (0, 0, 255, 255), 3)
|
||||||
|
cv2.line(drawPNG2, origin2, y_end2, (0, 255, 0, 255), 3)
|
||||||
|
cv2.line(drawPNG2, origin2, z_end2, (255, 0, 0, 255), 3)
|
||||||
|
|
||||||
|
cv2.putText(draw2, "X (200 mm)", x_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,255), 2)
|
||||||
|
cv2.putText(draw2, "Y (-100 mm)", y_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2)
|
||||||
|
cv2.putText(draw2, "+Z (100 mm)", z_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,0,0), 2)
|
||||||
|
|
||||||
|
cv2.putText(drawPNG2, "X (200 mm)", x_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255, 255), 2)
|
||||||
|
cv2.putText(drawPNG2, "Y (-100 mm)", y_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0, 255), 2)
|
||||||
|
cv2.putText(drawPNG2, "+Z (100 mm)", z_end2, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0, 255), 2)
|
||||||
|
|
||||||
|
out_img2 = f"{base2}_two_cam_annotated.jpg"
|
||||||
|
cv2.imwrite(out_img2, draw2)
|
||||||
|
print(f"[INFO] Annotated image saved as '{out_img2}'.")
|
||||||
|
|
||||||
|
gray2 = cv2.cvtColor(drawPNG2, cv2.COLOR_BGR2GRAY)
|
||||||
|
_, alpha2 = cv2.threshold(gray2, 0, 255, cv2.THRESH_BINARY)
|
||||||
|
drawPNG_2 = cv2.merge([drawPNG2[:, :, 0], drawPNG2[:, :, 1], drawPNG2[:, :, 2], alpha2])
|
||||||
|
out_png2 = f"{base2}_two_cam_overlay.png"
|
||||||
|
cv2.imwrite(out_png2, drawPNG_2)
|
||||||
|
print(f"[INFO] Overlay PNG saved as '{out_png2}'.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to draw machine axes for camera2: {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
135
programs/screenShot.js
Executable file
135
programs/screenShot.js
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { logSnapshot } = require('./log');
|
||||||
|
|
||||||
|
|
||||||
|
function snapshot(outDir, cam0, cam1, ws){
|
||||||
|
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const picDate = Date.now();
|
||||||
|
const name0 = `snapshot_video0_${picDate}.jpg`;
|
||||||
|
const name1 = `snapshot_video1_${picDate}.jpg`;
|
||||||
|
cam0.snapshot(path.join(outDir, name0));
|
||||||
|
cam1.snapshot(path.join(outDir, name1));
|
||||||
|
|
||||||
|
|
||||||
|
strFile0 = path.join(outDir, name0);
|
||||||
|
strFile1 = path.join(outDir, name1);
|
||||||
|
|
||||||
|
const relUrl = `/snapshots/${name0}`;
|
||||||
|
const relUrlApp = `/snapshots/${name0.replace('.jpg','_two_cam_annotated.jpg')}`;
|
||||||
|
// The Python postprocessor writes an overlay named "_two_cam_overlay.png" and a CSV named "_two_cam.csv"
|
||||||
|
const relOverlay = `/snapshots/${name0.replace('.jpg','_two_cam_overlay.png')}`;
|
||||||
|
const relOverlayCSV = `/snapshots/${name0.replace('.jpg','_two_cam.csv')}`;
|
||||||
|
|
||||||
|
const annotatedPath = path.join(outDir, name0.replace('.jpg','_two_cam_annotated.jpg'));
|
||||||
|
const overlayPath = path.join(outDir, name0.replace('.jpg','_two_cam_overlay.png'));
|
||||||
|
const csvPath = path.join(outDir, name0.replace('.jpg','_two_cam.csv'));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const command = `python3 /usr/src/app/programs/readTwoImages.py -i ${strFile0} -i ${strFile1} -npz /usr/src/app/data/settings/callibration_cam0.npz -npz /usr/src/app/data/settings/callibration_cam1.npz -settings /usr/src/app/data/settings/settings.json`;
|
||||||
|
console.log("Executing Python " + command);
|
||||||
|
|
||||||
|
// Run the Python post-processing and send the snapshot response only
|
||||||
|
// after the annotated files are present to avoid transient 404s in the browser.
|
||||||
|
exec(command, (error, stdout, stderr) => {
|
||||||
|
try {
|
||||||
|
if (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
// Capture which generated files actually exist for debugging
|
||||||
|
const files = {
|
||||||
|
annotated: fs.existsSync(annotatedPath),
|
||||||
|
overlay: fs.existsSync(overlayPath),
|
||||||
|
csv: fs.existsSync(csvPath)
|
||||||
|
};
|
||||||
|
// Log full details server-side for diagnosis
|
||||||
|
const detailed = {
|
||||||
|
type: 'snapshot',
|
||||||
|
ok: false,
|
||||||
|
error: error.message,
|
||||||
|
stdout: String(stdout).slice(0, 4096),
|
||||||
|
stderr: String(stderr).slice(0, 4096),
|
||||||
|
files
|
||||||
|
};
|
||||||
|
logSnapshot(command, JSON.stringify(detailed));
|
||||||
|
|
||||||
|
// Send a short, user-friendly error to the client (no large stdout/stderr)
|
||||||
|
const shortError = String(stderr || error.message || '').includes('Corrupt JPEG')
|
||||||
|
? 'postprocessor failed: corrupt JPEG input'
|
||||||
|
: 'postprocessor failed';
|
||||||
|
try { ws.send(JSON.stringify({ type: 'snapshot', ok: false, error: shortError })); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
// Log stderr but don't fail outright; sometimes tools output warnings on stderr.
|
||||||
|
if (String(stderr).trim()) console.error(`Stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Output:\n${stdout}`);
|
||||||
|
|
||||||
|
// Wait up to ~1s (10 * 100ms) for the generated files to appear on disk.
|
||||||
|
const waitForFiles = (paths, attempts = 10, delayMs = 100) => new Promise((resolve) => {
|
||||||
|
let tries = 0;
|
||||||
|
(function poll() {
|
||||||
|
const ok = paths.every(p => fs.existsSync(p));
|
||||||
|
if (ok || tries >= attempts) return resolve(ok);
|
||||||
|
tries++;
|
||||||
|
setTimeout(poll, delayMs);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
waitForFiles([annotatedPath, overlayPath, csvPath]).then((found) => {
|
||||||
|
if (!found) {
|
||||||
|
const files = {
|
||||||
|
annotated: fs.existsSync(annotatedPath),
|
||||||
|
overlay: fs.existsSync(overlayPath),
|
||||||
|
csv: fs.existsSync(csvPath)
|
||||||
|
};
|
||||||
|
// Log details server-side
|
||||||
|
const detailed = {
|
||||||
|
type: 'snapshot',
|
||||||
|
ok: false,
|
||||||
|
url: relUrl,
|
||||||
|
urlApp: relUrlApp,
|
||||||
|
overlay: relOverlay,
|
||||||
|
overlayCSV: relOverlayCSV,
|
||||||
|
files
|
||||||
|
};
|
||||||
|
logSnapshot(command, JSON.stringify(detailed));
|
||||||
|
// Send a concise error to the client
|
||||||
|
try { ws.send(JSON.stringify({ type: 'snapshot', ok: false, error: 'postprocessor incomplete (missing outputs)' })); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = JSON.stringify({
|
||||||
|
type: 'snapshot',
|
||||||
|
ok: found,
|
||||||
|
url: relUrl,
|
||||||
|
urlApp: relUrlApp,
|
||||||
|
overlay: relOverlay,
|
||||||
|
overlayCSV: relOverlayCSV
|
||||||
|
});
|
||||||
|
logSnapshot(command, response);
|
||||||
|
try { ws.send(response); } catch (e) {}
|
||||||
|
}).catch((waitErr) => {
|
||||||
|
console.error('waitForFiles failed:', waitErr);
|
||||||
|
const response = JSON.stringify({ type: 'snapshot', ok: false, error: String(waitErr) });
|
||||||
|
logSnapshot(command, response);
|
||||||
|
try { ws.send(response); } catch (e) {}
|
||||||
|
});
|
||||||
|
} catch (handlerErr) {
|
||||||
|
console.error('snapshot handler error:', handlerErr);
|
||||||
|
const response = JSON.stringify({ type: 'snapshot', ok: false, error: String(handlerErr) });
|
||||||
|
logSnapshot(command, response);
|
||||||
|
try { ws.send(response); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { snapshot };
|
||||||
234
programs/videoServer.js
Executable file
234
programs/videoServer.js
Executable file
@@ -0,0 +1,234 @@
|
|||||||
|
// programs/videoServer.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
class JpegFrameSplitter {
|
||||||
|
constructor(onFrame) {
|
||||||
|
this.onFrame = onFrame;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
push(chunk) {
|
||||||
|
if (!chunk || !chunk.length) return;
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
let start = this.buffer.indexOf(Buffer.from([0xFF, 0xD8]));
|
||||||
|
while (start !== -1) {
|
||||||
|
const end = this.buffer.indexOf(Buffer.from([0xFF, 0xD9]), start + 2);
|
||||||
|
if (end === -1) break;
|
||||||
|
const frame = this.buffer.slice(start, end + 2);
|
||||||
|
try { this.onFrame(frame); } catch {}
|
||||||
|
this.buffer = this.buffer.slice(end + 2);
|
||||||
|
start = this.buffer.indexOf(Buffer.from([0xFF, 0xD8]));
|
||||||
|
}
|
||||||
|
if (this.buffer.length > 8 * 1024 * 1024) {
|
||||||
|
const nextSOI = this.buffer.indexOf(Buffer.from([0xFF, 0xD8]));
|
||||||
|
this.buffer = nextSOI !== -1 ? this.buffer.slice(nextSOI) : Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FFmpegStreamer {
|
||||||
|
/**
|
||||||
|
* devicePath: '/dev/videoX'
|
||||||
|
* options: {
|
||||||
|
* name, width, height, fps, quality,
|
||||||
|
* input: { format, fps, size, useWallclock, threadQueueSize, channel },
|
||||||
|
* tryFormats: [ 'mjpeg', undefined, 'yuyv422', 'rgb24' ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
constructor(devicePath, options = {}) {
|
||||||
|
this.devicePath = devicePath;
|
||||||
|
this.name = options.name || devicePath;
|
||||||
|
this.opts = {
|
||||||
|
width: options.width ?? undefined,
|
||||||
|
height: options.height ?? undefined,
|
||||||
|
fps: options.fps ?? 20,
|
||||||
|
quality: options.quality ?? 5,
|
||||||
|
input: {
|
||||||
|
format: options.input?.format,
|
||||||
|
fps: options.input?.fps,
|
||||||
|
size: options.input?.size,
|
||||||
|
useWallclock: options.input?.useWallclock ?? true,
|
||||||
|
threadQueueSize: options.input?.threadQueueSize ?? 64,
|
||||||
|
channel: options.input?.channel,
|
||||||
|
},
|
||||||
|
tryFormats: (options.tryFormats || [options.input?.format, 'yuyv422', 'mjpeg', 'rgb24'])
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.proc = null;
|
||||||
|
this.clients = new Set();
|
||||||
|
this.startedAt = null;
|
||||||
|
this.latestFrame = null;
|
||||||
|
this.splitter = null;
|
||||||
|
|
||||||
|
this.formatIdx = 0;
|
||||||
|
this.currentFormat = this.opts.tryFormats[this.formatIdx];
|
||||||
|
|
||||||
|
this._restarting = false;
|
||||||
|
this._backoffMs = 500;
|
||||||
|
this._maxBackoffMs = 8000;
|
||||||
|
this._stderrBuf = [];
|
||||||
|
this._stderrMaxLines = 8;
|
||||||
|
|
||||||
|
this._quickFailCount = 0;
|
||||||
|
this._quickFailLimit = 6;
|
||||||
|
this._suspendedUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get running() { return !!this.proc; }
|
||||||
|
_scaling() { return Number(this.opts.width) > 0 && Number(this.opts.height) > 0; }
|
||||||
|
|
||||||
|
_buildFfmpegArgs() {
|
||||||
|
const outFps = this.opts.fps;
|
||||||
|
const quality = this.opts.quality;
|
||||||
|
const scaling = this._scaling();
|
||||||
|
|
||||||
|
const inFmt = this.currentFormat;
|
||||||
|
const inFps = this.opts.input.fps;
|
||||||
|
const inSize = this.opts.input.size;
|
||||||
|
const useWallclock = this.opts.input.useWallclock;
|
||||||
|
const tqs = this.opts.input.threadQueueSize;
|
||||||
|
const inChannel = this.opts.input.channel;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-nostdin',
|
||||||
|
'-f', 'video4linux2',
|
||||||
|
...(tqs ? ['-thread_queue_size', String(tqs)] : []),
|
||||||
|
...(inFmt ? ['-input_format', String(inFmt)] : []),
|
||||||
|
...(inFps ? ['-framerate', String(inFps)] : []),
|
||||||
|
...(inSize ? ['-video_size', String(inSize)] : []),
|
||||||
|
...(typeof inChannel === 'number' ? ['-channel', String(inChannel)] : []),
|
||||||
|
...(useWallclock ? ['-use_wallclock_as_timestamps', '1'] : []),
|
||||||
|
'-i', this.devicePath,
|
||||||
|
'-fflags', 'nobuffer', '-flags', 'low_delay', '-an', '-sn',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (inFmt === 'mjpeg' && !scaling) {
|
||||||
|
args.push('-vsync', 'passthrough', '-c:v', 'copy', '-f', 'mjpeg', 'pipe:1');
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
if (scaling) args.push('-vf', `scale=${Number(this.opts.width)}:${Number(this.opts.height)}`);
|
||||||
|
if (outFps) args.push('-r', String(outFps));
|
||||||
|
args.push('-f', 'mjpeg', '-q:v', String(quality), 'pipe:1');
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logStderr(d) {
|
||||||
|
const s = d.toString().trim();
|
||||||
|
if (!s) return;
|
||||||
|
this._stderrBuf.push(s);
|
||||||
|
if (this._stderrBuf.length > this._stderrMaxLines) this._stderrBuf.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.proc) return;
|
||||||
|
if (Date.now() < this._suspendedUntil) {
|
||||||
|
const wait = this._suspendedUntil - Date.now();
|
||||||
|
console.warn(`[FFmpeg] ${this.name} suspended for ${wait}ms due to repeated failures`);
|
||||||
|
return setTimeout(() => this.start(), wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = this._buildFfmpegArgs();
|
||||||
|
console.log(`[FFmpeg] Start ${this.devicePath} (${this.name}) :: ${args.join(' ')}`);
|
||||||
|
|
||||||
|
this._stderrBuf = [];
|
||||||
|
this.proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
||||||
|
this.startedAt = Date.now();
|
||||||
|
|
||||||
|
this.splitter = new JpegFrameSplitter((frame) => {
|
||||||
|
this.latestFrame = frame;
|
||||||
|
this._broadcast(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.proc.stdout.on('data', (chunk) => this.splitter?.push(chunk));
|
||||||
|
this.proc.stderr.on('data', (d) => this._logStderr(d));
|
||||||
|
|
||||||
|
this.proc.on('exit', (code, signal) => {
|
||||||
|
console.warn(`[FFmpeg] ${this.devicePath} exited code=${code} sig=${signal}`);
|
||||||
|
if (this._stderrBuf.length) console.warn(`[FFmpeg] ${this.name} last errors:\n - ${this._stderrBuf.join('\n - ')}`);
|
||||||
|
|
||||||
|
this.proc = null;
|
||||||
|
const quick = (Date.now() - (this.startedAt || Date.now())) < 2000;
|
||||||
|
this.startedAt = null;
|
||||||
|
|
||||||
|
if (quick && !this._restarting) {
|
||||||
|
this._quickFailCount++;
|
||||||
|
this.formatIdx = (this.formatIdx + 1) % this.opts.tryFormats.length;
|
||||||
|
this.currentFormat = this.opts.tryFormats[this.formatIdx];
|
||||||
|
console.warn(`[FFmpeg] ${this.name}: quick failure -> trying next format: ${this.currentFormat}`);
|
||||||
|
} else {
|
||||||
|
this._quickFailCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._quickFailCount >= this._quickFailLimit) {
|
||||||
|
this._suspendedUntil = Date.now() + 60000; // 60s pause
|
||||||
|
this._quickFailCount = 0;
|
||||||
|
console.error(`[FFmpeg] ${this.name}: too many quick failures; suspending restarts for 60s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = this._restarting ? 300 : Math.min(this._backoffMs, this._maxBackoffMs);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this._restarting) this._backoffMs = Math.min(this._backoffMs * 2, this._maxBackoffMs);
|
||||||
|
else this._backoffMs = 500;
|
||||||
|
this.start();
|
||||||
|
}, delay);
|
||||||
|
this._restarting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_killProcessGroup(signal = 'SIGTERM') {
|
||||||
|
if (!this.proc) return;
|
||||||
|
try {
|
||||||
|
if (process.platform !== 'win32') process.kill(-this.proc.pid, signal);
|
||||||
|
else this.proc.kill(signal);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.proc) return;
|
||||||
|
this._restarting = false;
|
||||||
|
this._killProcessGroup('SIGTERM');
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(newOpts = {}) {
|
||||||
|
this._restarting = true;
|
||||||
|
if (newOpts.input) this.opts.input = { ...this.opts.input, ...newOpts.input };
|
||||||
|
this.opts = { ...this.opts, ...newOpts, input: this.opts.input };
|
||||||
|
if (newOpts.input && Object.prototype.hasOwnProperty.call(newOpts.input, 'format')) {
|
||||||
|
const idx = this.opts.tryFormats.indexOf(this.opts.input.format);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.formatIdx = idx;
|
||||||
|
this.currentFormat = this.opts.tryFormats[this.formatIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.proc) this._killProcessGroup('SIGTERM'); else { this._restarting = false; this.start(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(ws) {
|
||||||
|
this.clients.add(ws);
|
||||||
|
if (this.latestFrame && ws.readyState === WebSocket.OPEN) {
|
||||||
|
try { ws.send(this.latestFrame, { binary: true }); } catch {}
|
||||||
|
}
|
||||||
|
ws.on('close', () => this.clients.delete(ws));
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(toFile) {
|
||||||
|
if (!this.latestFrame) throw new Error('No frame available yet');
|
||||||
|
fs.writeFileSync(toFile, this.latestFrame);
|
||||||
|
return toFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcast(frame) {
|
||||||
|
if (!this.clients.size) return;
|
||||||
|
for (const ws of this.clients) {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
if (ws.bufferedAmount > 512 * 1024) continue; // drop if back-pressured
|
||||||
|
try { ws.send(frame, { binary: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { FFmpegStreamer };
|
||||||
110
public/GamePad.js
Executable file
110
public/GamePad.js
Executable file
@@ -0,0 +1,110 @@
|
|||||||
|
var isRunning = false;
|
||||||
|
var gamePadId = 0;
|
||||||
|
var gamepad = {};
|
||||||
|
|
||||||
|
let lastCheckTime = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function checkGamePad() {
|
||||||
|
if(isRunning == false){return;}
|
||||||
|
|
||||||
|
const stepSize = "0.01";
|
||||||
|
const stepSizeXYZ = "0.5"; // 3 ist auch ok
|
||||||
|
const stepSizeE = "0.01";
|
||||||
|
var gp = navigator.getGamepads()[gamePadId]
|
||||||
|
var buttons = gp.buttons
|
||||||
|
|
||||||
|
var xyzSpeed = 10; // 100 geht auch
|
||||||
|
|
||||||
|
var psi = gp.axes[0];
|
||||||
|
var z = gp.axes[1];
|
||||||
|
var x = gp.axes[2];
|
||||||
|
var y = gp.axes[3];
|
||||||
|
|
||||||
|
|
||||||
|
// Dreieck zum Dreieck-Setzen
|
||||||
|
if (buttons[3].pressed) {
|
||||||
|
socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B${-1.0*Math.PI/2} C0 F${xyzSpeed}`);
|
||||||
|
}
|
||||||
|
if (buttons[4].pressed) {
|
||||||
|
//console.log("x=" + robot.x + " y=" + robot.y + " z=" + robot.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// X Button setzt eine Marke
|
||||||
|
if(buttons[0].pressed && (Date.now() - lastCheckTime > 500)){
|
||||||
|
lastCheckTime = Date.now()
|
||||||
|
console.log('FPoint!');
|
||||||
|
socketDriver.send('FPoint');
|
||||||
|
socketDriver.send('FShow');
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 und R1 Button to forward-backward in Point-List
|
||||||
|
if(gp.buttons[4].pressed && (Date.now() - lastCheckTime > 500)){
|
||||||
|
lastCheckTime = Date.now()
|
||||||
|
socketDriver.send('FMinus');
|
||||||
|
socketDriver.send('FShow');
|
||||||
|
}
|
||||||
|
if(gp.buttons[5].pressed && (Date.now() - lastCheckTime > 500)){
|
||||||
|
lastCheckTime = Date.now()
|
||||||
|
socketDriver.send('FPlus');
|
||||||
|
socketDriver.send('FShow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x < -0.2) { socketDriver.send(`G91 G1 X+${stepSizeXYZ} F${xyzSpeed}`);}
|
||||||
|
if (x > 0.2) { socketDriver.send(`G91 G1 X-${stepSizeXYZ} F${xyzSpeed}`);}
|
||||||
|
|
||||||
|
if (y < -0.2) { socketDriver.send(`G91 G1 Y${stepSizeXYZ} F${xyzSpeed}`); }
|
||||||
|
if (y > 0.2) { socketDriver.send(`G91 G1 Y-${stepSizeXYZ} F${xyzSpeed}`);}
|
||||||
|
|
||||||
|
if (z < -0.2) { socketDriver.send(`G91 G1 Z${stepSizeXYZ} F${xyzSpeed}`); }
|
||||||
|
if (z > 0.2) { socketDriver.send(`G91 G1 Z-${stepSizeXYZ} F${xyzSpeed}`); }
|
||||||
|
|
||||||
|
|
||||||
|
// Greif-Richtung
|
||||||
|
// LeftRight
|
||||||
|
if(buttons[14].pressed){ socketDriver.send(`G91 G1 A${stepSize} F${xyzSpeed}`);}
|
||||||
|
if(buttons[15].pressed){ socketDriver.send(`G91 G1 A-${stepSize} F${xyzSpeed}`);}
|
||||||
|
// Up - Down
|
||||||
|
if(buttons[12].pressed){ socketDriver.send(`G91 G1 B${stepSize} F${xyzSpeed}`);}
|
||||||
|
if(buttons[13].pressed){ socketDriver.send(`G91 G1 B-${stepSize} F${xyzSpeed}`);}
|
||||||
|
// Drehung
|
||||||
|
if (psi < -0.2) { socketDriver.send(`G91 G1 C${stepSize} F${xyzSpeed}`); }
|
||||||
|
if (psi > 0.2) { socketDriver.send(`G91 G1 C-${stepSize} F${xyzSpeed}`); }
|
||||||
|
|
||||||
|
// Trigger-Buttons für Öffnen und Schliessen
|
||||||
|
if(buttons[6].pressed){socketDriver.send(`G91 G1 E-${stepSizeE} F${xyzSpeed}`);}
|
||||||
|
if(buttons[7].pressed){socketDriver.send(`G91 G1 E${stepSizeE} F${xyzSpeed}`);}
|
||||||
|
|
||||||
|
if (isRunning) { setTimeout(checkGamePad, 15);}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gamepadHandler(event, connecting) {
|
||||||
|
|
||||||
|
gamepad = event.gamepad;
|
||||||
|
if (typeof gamepad === `undefined`) {
|
||||||
|
isRunning = false;
|
||||||
|
gamePadId = 0;
|
||||||
|
console.warn("GamePad kann nicht gefunden werden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connecting) {
|
||||||
|
console.log("GamePad " + event.gamepad.index + " connected");
|
||||||
|
gamePadId = gamepad.index;
|
||||||
|
isRunning = true;
|
||||||
|
setTimeout(checkGamePad, 20);
|
||||||
|
} else {
|
||||||
|
console.log("GamePad " +gamePadId +" disconnected");
|
||||||
|
isRunning = false;
|
||||||
|
gamePadId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
window.addEventListener("gamepadconnected", function (e) { gamepadHandler(e, true); }, false);
|
||||||
|
window.addEventListener("gamepaddisconnected", function (e) { gamepadHandler(e, false); }, false);
|
||||||
|
|
||||||
|
document.addEventListener("touchstart", e => { console.log("TouchStart"); })
|
||||||
64
public/KeyboardInput.js
Executable file
64
public/KeyboardInput.js
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
document.onkeydown = checkKey;
|
||||||
|
|
||||||
|
function checkKey(e) {
|
||||||
|
|
||||||
|
e = e || window.event;
|
||||||
|
|
||||||
|
if(e.key == 'a' || e.key == 'A')
|
||||||
|
{
|
||||||
|
console.log("back to A position");
|
||||||
|
socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B-${Math.PI/2} C0 F100`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-Winkel (Eulerwinkel)
|
||||||
|
else if(e.key == 'i' || e.key == 'I'){
|
||||||
|
socketDriver.send('G91 G1 B+0.1 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'k' || e.key == 'K'){
|
||||||
|
socketDriver.send('G91 G1 B-0.1 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'l' || e.key == 'L'){
|
||||||
|
socketDriver.send('G91 G1 A+0.1 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'j' || e.key == 'J'){
|
||||||
|
socketDriver.send('G91 G1 A-0.1 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'o' || e.key == 'O'){
|
||||||
|
socketDriver.send('G91 G1 C+0.1 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'u' || e.key == 'U'){
|
||||||
|
socketDriver.send('G91 G1 C-0.1 F100');
|
||||||
|
}
|
||||||
|
|
||||||
|
// XYZ Koordinaten
|
||||||
|
else if(e.key == 'e' || e.key == 'E'){
|
||||||
|
socketDriver.send('G91 G1 Z+5 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'd' || e.key == 'D'){
|
||||||
|
socketDriver.send('G91 G1 Z-5 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 's' || e.key == 'S'){
|
||||||
|
socketDriver.send('G91 G1 X5 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'f' || e.key == 'S'){
|
||||||
|
socketDriver.send('G91 G1 X-5 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'r' || e.key == 'R'){
|
||||||
|
socketDriver.send('G91 G1 Y5 F100');
|
||||||
|
}
|
||||||
|
else if(e.key == 'w' || e.key == 'W'){
|
||||||
|
socketDriver.send('G91 G1 Y-5 F100');
|
||||||
|
}
|
||||||
|
|
||||||
|
// File & Log-Operations
|
||||||
|
else if(e.key == ' '){
|
||||||
|
console.log('FPoint!')
|
||||||
|
socketDriver.send('FPoint');
|
||||||
|
}
|
||||||
|
else if(e.key == 'b'){
|
||||||
|
socketDriver.send('FMinus');
|
||||||
|
}
|
||||||
|
else if(e.key == 'n'){
|
||||||
|
socketDriver.send('FPlus');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
public/WebSocket.js
Executable file
25
public/WebSocket.js
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
const protocolS = location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
const robotURL = protocolS + location.host + "/ws/robot";
|
||||||
|
console.log("socketDriver try to connect to "+ robotURL);
|
||||||
|
|
||||||
|
const socketDriver = new WebSocket(robotURL);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
socketDriver.onopen = () => { console.log("socketDriver WebSocket connected"); };
|
||||||
|
|
||||||
|
socketDriver.onmessage = (event) => {
|
||||||
|
if(event.data.toString().includes("position")){
|
||||||
|
console.log("Position: " + event.data);
|
||||||
|
}
|
||||||
|
else if(event.data.toString().includes("XYZ__FShow__XYZ")){
|
||||||
|
const content = event.data.toString().split("XYZ__FShow__XYZ")[1];
|
||||||
|
document.querySelectorAll("textarea#GCodeWindow.editor-look")[0].value = content;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
console.log('DATA SinceStartup: ' + (Date.now() - startTime).toString() +': ', event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socketDriver.onclose = () => {console.log("socketDriver WebSocket is closing");};
|
||||||
|
socketDriver.onerror = (err) => { console.error("socketDriver socket error:", err); };
|
||||||
72
public/app.js
Executable file
72
public/app.js
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
// /public/app.js
|
||||||
|
// Small bootstrap moved out of index.html to satisfy CSP (`script-src 'self'`).
|
||||||
|
(function () {
|
||||||
|
const isSecure = location.protocol === 'https:';
|
||||||
|
const wsProto = isSecure ? 'wss' : 'ws';
|
||||||
|
const base = `${wsProto}://${location.host}`;
|
||||||
|
|
||||||
|
// Camera 0 with control channel
|
||||||
|
window.VideoService.attachStream({
|
||||||
|
url: `${base}/ws/video0`,
|
||||||
|
canvas: document.getElementById('canvas0'),
|
||||||
|
statusEl: document.getElementById('status0'),
|
||||||
|
control: {
|
||||||
|
resSelect: document.getElementById('res0'),
|
||||||
|
fpsSelect: document.getElementById('fps0'),
|
||||||
|
qSelect: document.getElementById('q0'),
|
||||||
|
applyBtn: document.getElementById('apply0'),
|
||||||
|
snapshotBtn: document.getElementById('snap0'),
|
||||||
|
startBtn: document.getElementById('start0'),
|
||||||
|
stopBtn: document.getElementById('stop0'),
|
||||||
|
snapshotOutEl: document.getElementById('snapshotLink'),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Camera 1 (no control)
|
||||||
|
window.VideoService.attachStream({
|
||||||
|
url: `${base}/ws/video1`,
|
||||||
|
canvas: document.getElementById('canvas1'),
|
||||||
|
statusEl: document.getElementById('status1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run an automatic snapshot shortly after the page loads so the client
|
||||||
|
// receives the freshest annotated image/CSV on startup. This uses a
|
||||||
|
// synthetic click on the snapshot button; the handler will no-op if the
|
||||||
|
// WebSocket isn't open yet.
|
||||||
|
setTimeout(() => {
|
||||||
|
const snapBtn = document.getElementById('snap0');
|
||||||
|
if (snapBtn) snapBtn.click();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Attach handlers for the Point/Up/Down buttons (no inline JS, CSP-safe)
|
||||||
|
const btnPoint = document.getElementById('btnPoint');
|
||||||
|
const btnDown = document.getElementById('btnDown');
|
||||||
|
const btnUp = document.getElementById('btnUp');
|
||||||
|
if (btnPoint) btnPoint.addEventListener('click', () => {
|
||||||
|
console.log('FPoint!');
|
||||||
|
try { socketDriver.send('FPoint'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
if (btnDown) btnDown.addEventListener('click', () => {
|
||||||
|
console.log('FPlus');
|
||||||
|
try { socketDriver.send('FPlus'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
if (btnUp) btnUp.addEventListener('click', () => {
|
||||||
|
console.log('FMinus');
|
||||||
|
try { socketDriver.send('FMinus'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other buttons that were previously using inline onclicks.
|
||||||
|
const btnInfo = document.getElementById('b_M114');
|
||||||
|
const btnNull = document.getElementById('b_G28');
|
||||||
|
const btnListFile = document.getElementById('btnListFile');
|
||||||
|
const btnSendGCode = document.getElementById('btnSendGCode');
|
||||||
|
if (btnInfo) btnInfo.addEventListener('click', () => { try { socketDriver.send('M114'); } catch (e) { console.error(e); } });
|
||||||
|
if (btnNull) btnNull.addEventListener('click', () => { try { socketDriver.send('G28'); } catch (e) { console.error(e); } });
|
||||||
|
if (btnListFile) btnListFile.addEventListener('click', () => { try { socketDriver.send('FShow'); } catch (e) { console.error(e); } });
|
||||||
|
if (btnSendGCode) btnSendGCode.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const input = document.getElementById('GKarth');
|
||||||
|
if (input && input.value) socketDriver.send(input.value);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
23
public/buttonCmd.js
Executable file
23
public/buttonCmd.js
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
|
||||||
|
document.getElementById("b_uparrow").addEventListener('click',() => { socketDriver.send(`G91 G1 Z10 F100`);});
|
||||||
|
document.getElementById("b_downarw").addEventListener('click',() => { socketDriver.send(`G90 G1 Z-10 F100`);});
|
||||||
|
document.getElementById("b_right").addEventListener('click',() => { socketDriver.send(`G90 G1 X10 F100`);});
|
||||||
|
document.getElementById("b_left").addEventListener('click',() => { socketDriver.send(`G90 G1 X-10 F100`);});
|
||||||
|
document.getElementById("b_forward").addEventListener('click',() => { socketDriver.send(`G90 G1 Y10 F100`);});
|
||||||
|
document.getElementById("b_backward").addEventListener('click',() => { socketDriver.send(`G90 G1 Y-10 F100`);});
|
||||||
|
|
||||||
|
document.getElementById("b_aPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 A0.10 F100`);});
|
||||||
|
document.getElementById("b_aMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 A-0.10 F100`);});
|
||||||
|
document.getElementById("b_bPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 B0.10 F100`);});
|
||||||
|
document.getElementById("b_bMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 B-0.10 F100`);});
|
||||||
|
document.getElementById("b_cPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 C0.10 F100`);});
|
||||||
|
document.getElementById("b_cMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 C-0.10 F100`);});
|
||||||
|
|
||||||
|
document.getElementById("b_ePlus").addEventListener('click',() => { socketDriver.send(`G91 G1 E0.10 F100`);});
|
||||||
|
document.getElementById("b_eMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 E-0.10 F100`);});
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById("b_default").addEventListener('click',() => { socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B-${Math.PI/2} C0 F100`);});
|
||||||
|
document.getElementById("b_G28").addEventListener('click',() => { socketDriver.send(`G28`);});
|
||||||
|
document.getElementById("b_M114").addEventListener('click',() => { socketDriver.send(`M114`);});
|
||||||
108
public/index.html
Executable file
108
public/index.html
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>Dual Camera Stream (HTTPS + WSS)</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<script src="WebSocket.js" defer></script>
|
||||||
|
<script src="buttonCmd.js" defer></script>
|
||||||
|
<script src="GamePad.js" defer></script>
|
||||||
|
<script src="KeyboardInput.js" defer></script>
|
||||||
|
<link rel="stylesheet" href="indexA.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Robot Control</h1>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Camera 0 -->
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Camera 0</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="video-wrap">
|
||||||
|
<canvas id="canvas0" width="1280" height="720"></canvas>
|
||||||
|
<img id="overlayImg"
|
||||||
|
src="/snapshots/snapshot_video0_1765356085372_two_cam_overlay.png">
|
||||||
|
</div>
|
||||||
|
<div id="status0" class="status">Connecting…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Camera 1 -->
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Camera 1</h2>
|
||||||
|
<div class="video-wrap">
|
||||||
|
<canvas id="canvas1" width="1280" height="720"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="status1" class="status">Connecting…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="controls">
|
||||||
|
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.5rem; align-items:end;">
|
||||||
|
<button id="snap0">Snapshot</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="snapshotLink" class="status"></div>
|
||||||
|
<div id="csvTable" class="status"></div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
|
||||||
|
<div id="divButtons">
|
||||||
|
<h3>Controls</h3>
|
||||||
|
<button id="b_uparrow">Up</button>
|
||||||
|
<button id="b_downarw">Down</button>
|
||||||
|
</br/>
|
||||||
|
<button id="b_right">Right</button>
|
||||||
|
<button id="b_left">Left</button>
|
||||||
|
<br/>
|
||||||
|
<button id="b_forward">Forward</button>
|
||||||
|
<button id="b_backward">Backward</button>
|
||||||
|
<br/>
|
||||||
|
<button id="b_M114">Info</button>
|
||||||
|
<button id="b_G28">Null</button>
|
||||||
|
<button id="b_default">△ Default</button>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<button id="b_aPlus" >a+</button>
|
||||||
|
<button id="b_aMinus">a-</button>
|
||||||
|
<br/>
|
||||||
|
<button id="b_bPlus" >B+</button>
|
||||||
|
<button id="b_bMinus">B-</button>
|
||||||
|
<br/>
|
||||||
|
<button id="b_cPlus" >C+</button>
|
||||||
|
<button id="b_cMinus">C-</button>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
<button id="b_ePlus" >E+</button>
|
||||||
|
<button id="b_eMinus">E-</button>
|
||||||
|
</div>
|
||||||
|
<div id="divGCodeWindow">
|
||||||
|
<div id="GCode">
|
||||||
|
<form onsubmit="return false;"><input id="GKarth"/><button id="btnSendGCode" type="button">Send GCode</button></form>
|
||||||
|
<br/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btnListFile" type="button">ListFile</button>
|
||||||
|
<br/>
|
||||||
|
<textarea id="GCodeWindow" class="editor-look" readonly>
|
||||||
|
|
||||||
|
</textarea>
|
||||||
|
<br/>
|
||||||
|
<button id="btnPoint">🗙 Point</button>
|
||||||
|
<button id="btnDown">⇩</button>
|
||||||
|
<button id="btnUp">⇧</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External scripts only (CSP-safe, no inline JS) -->
|
||||||
|
<script src="/readCSV.js" defer></script>
|
||||||
|
<script src="/videoService.js" defer></script>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
203
public/indexA.css
Executable file
203
public/indexA.css
Executable file
@@ -0,0 +1,203 @@
|
|||||||
|
:root { color-scheme: dark light; }
|
||||||
|
/* Overlay opacity is configurable here. Set to 1 to let PNG alpha be authoritative */
|
||||||
|
:root {
|
||||||
|
--overlay-opacity: 1; /* default overlay alpha applied client-side */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #0b1020;
|
||||||
|
color: #e7eaf6;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
/*
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1280px;
|
||||||
|
justify-content: start;
|
||||||
|
*/
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(1280px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #141b34;
|
||||||
|
border: 1px solid #242c4f;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Keep a 16:9 box that doesn't stretch beyond the source resolution */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure canvas and overlay are sized identically and keep the same aspect ratio
|
||||||
|
so overlays align correctly when the window is resized. */
|
||||||
|
.video-wrap canvas,
|
||||||
|
.video-wrap img,
|
||||||
|
#overlayImg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0; /* top:0; right:0; bottom:0; left:0 */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* Use `contain` so both layers scale uniformly and show letterboxing instead
|
||||||
|
of stretching or cropping when the container size differs from the image. */
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap canvas {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.video-wrap img,
|
||||||
|
#overlayImg {
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none; /* don't block interactions with the canvas */
|
||||||
|
opacity: var(--overlay-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, input, button {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2d3766;
|
||||||
|
background: #0d1530;
|
||||||
|
color: #e7eaf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #355dff;
|
||||||
|
border-color: #355dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #9eb8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Ensure overlay uses contain and respects alpha channel from the PNG
|
||||||
|
(the PNG's transparency is the authoritative source of which pixels
|
||||||
|
are transparent; CSS opacity only adjusts global alpha). */
|
||||||
|
#overlayImg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--overlay-opacity);
|
||||||
|
object-fit: contain; /* preserve aspect ratio and don't distort */
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSV table styles */
|
||||||
|
.csv-table {
|
||||||
|
width: 440px; /* 4 columns * 200px each */
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
table-layout: fixed; /* use fixed layout so column widths are respected */
|
||||||
|
}
|
||||||
|
.csv-table th, .csv-table td {
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 110px; /* fixed column width */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.csv-table th {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.csv-table tr:nth-child(even) td {
|
||||||
|
background: rgba(255,255,255,0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-align CSV numeric data and use tabular digits for stable column alignment */
|
||||||
|
.csv-table td { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
#GCodeWindow {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout: place the buttons and GCode window side-by-side in their panel.
|
||||||
|
- `#divButtons` gets a fixed ~300px width
|
||||||
|
- `#divGCodeWindow` uses the remaining width
|
||||||
|
- On small screens they stack vertically for usability */
|
||||||
|
/* Use floats for a predictable two-column layout inside the panel. */
|
||||||
|
#divButtons {
|
||||||
|
float: left;
|
||||||
|
width: 300px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
#divGCodeWindow {
|
||||||
|
display: block;
|
||||||
|
margin-left: 320px; /* 300px left column + 1rem gap */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clear floats inside panels so subsequent content flows correctly */
|
||||||
|
.panel::after {
|
||||||
|
content: "";
|
||||||
|
display: table;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
#divButtons, #divGCodeWindow {
|
||||||
|
display: block;
|
||||||
|
float: none;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
public/readCSV.js
Executable file
76
public/readCSV.js
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
// public/readCSV.js
|
||||||
|
// Fetch a CSV file and render a small table into the given container.
|
||||||
|
// Usage: window.readCSV.renderCSV(containerOrId, csvUrl)
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
async function renderCSV(container, csvUrl) {
|
||||||
|
console.log("readCSV should start");
|
||||||
|
console.log('readCSV.renderCSV', container, csvUrl);
|
||||||
|
|
||||||
|
const el = (typeof container === 'string') ? document.getElementById(container) : container;
|
||||||
|
if (!el) return;
|
||||||
|
if (!csvUrl) {
|
||||||
|
el.innerHTML = '<em>No CSV URL provided</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = 'Loading CSV…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(csvUrl, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
el.innerHTML = `<em>CSV not found (HTTP ${res.status})</em>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
const lines = text.trim().split(/\r?\n/).filter(Boolean);
|
||||||
|
if (!lines.length) {
|
||||||
|
el.innerHTML = '<em>CSV is empty</em>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim());
|
||||||
|
// Look for id,x,y,z columns (case-insensitive)
|
||||||
|
const lower = headers.map(h => h.toLowerCase());
|
||||||
|
const ids = {
|
||||||
|
id: lower.indexOf('id'),
|
||||||
|
x: lower.indexOf('x_mm'),
|
||||||
|
y: lower.indexOf('y_mm'),
|
||||||
|
z: lower.indexOf('z_mm')
|
||||||
|
};
|
||||||
|
// If any of these are missing, show a helpful message
|
||||||
|
if (ids.id === -1 || ids.x === -1 || ids.y === -1 || ids.z === -1) {
|
||||||
|
el.innerHTML = `<em>CSV does not contain required columns: id, x, y, z</em> ${csvUrl}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build table
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'csv-table';
|
||||||
|
const thead = table.createTHead();
|
||||||
|
const thr = thead.insertRow();
|
||||||
|
['id', 'x [mm]', 'y [mm]', 'z [mm]'].forEach(h => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = h;
|
||||||
|
thr.appendChild(th);
|
||||||
|
});
|
||||||
|
const tbody = table.createTBody();
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const row = lines[i].split(',').map(c => c.trim());
|
||||||
|
// Skip if row doesn't have enough columns
|
||||||
|
if (row.length < headers.length) continue;
|
||||||
|
const tr = tbody.insertRow();
|
||||||
|
['id', 'x', 'y', 'z'].forEach(k => {
|
||||||
|
const td = tr.insertCell();
|
||||||
|
td.textContent = row[ids[k]] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = ''; // clear
|
||||||
|
el.appendChild(table);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('readCSV error', err);
|
||||||
|
el.innerHTML = `<em>Error loading CSV</em>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.readCSV = { renderCSV };
|
||||||
|
})();
|
||||||
221
public/videoService.js
Executable file
221
public/videoService.js
Executable file
@@ -0,0 +1,221 @@
|
|||||||
|
// /public/videoService.js
|
||||||
|
// Client-side helper for consuming binary JPEG frames over WebSocket,
|
||||||
|
// decoding with createImageBitmap, and drawing to a canvas with RAF.
|
||||||
|
// For /ws/video0, supports sending control messages as JSON text.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
class FrameRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true, willReadFrequently: false });
|
||||||
|
this.queue = [];
|
||||||
|
this.maxQueue = 2; // keep at most 2 pending frames to limit latency
|
||||||
|
this._rafId = null;
|
||||||
|
this._running = false;
|
||||||
|
this._lastDraw = 0;
|
||||||
|
}
|
||||||
|
enqueue(bitmap) {
|
||||||
|
if (this.queue.length >= this.maxQueue) {
|
||||||
|
// drop older frame to keep latency low
|
||||||
|
const old = this.queue.shift();
|
||||||
|
if (old && 'close' in old) try { old.close(); } catch {}
|
||||||
|
}
|
||||||
|
this.queue.push(bitmap);
|
||||||
|
if (!this._running) this.start();
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
if (this._running) return;
|
||||||
|
this._running = true;
|
||||||
|
const loop = (ts) => {
|
||||||
|
if (!this._running) return;
|
||||||
|
const frame = this.queue.shift();
|
||||||
|
if (frame) {
|
||||||
|
const w = this.canvas.width, h = this.canvas.height;
|
||||||
|
this.ctx.drawImage(frame, 0, 0, w, h);
|
||||||
|
if ('close' in frame) try { frame.close(); } catch {}
|
||||||
|
this._lastDraw = ts;
|
||||||
|
}
|
||||||
|
this._rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
this._rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this._running = false;
|
||||||
|
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
// cleanup queued frames
|
||||||
|
while (this.queue.length) {
|
||||||
|
const f = this.queue.shift();
|
||||||
|
if (f && 'close' in f) try { f.close(); } catch {}
|
||||||
|
}
|
||||||
|
// Clear canvas
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyStatus(el, state, extra = '') {
|
||||||
|
const color = {
|
||||||
|
connecting: '#9eb8ff',
|
||||||
|
open: '#8cffbf',
|
||||||
|
closed: '#ff9e9e',
|
||||||
|
error: '#ffb167'
|
||||||
|
}[state] || '#e7eaf6';
|
||||||
|
el.innerHTML = `<span style="color:${color}">${state.toUpperCase()}</span>${extra ? ' — ' + extra : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBitmap(blob) {
|
||||||
|
// createImageBitmap is widely supported; fallback to <img> if needed
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWH(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const [w, h] = String(value).split('x').map(Number);
|
||||||
|
if (!w || !h) return null;
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachStream({ url, canvas, statusEl, control }) {
|
||||||
|
const renderer = new FrameRenderer(canvas);
|
||||||
|
let ws;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
let closedOnPurpose = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
prettyStatus(statusEl, 'connecting', url);
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.binaryType = 'blob';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
prettyStatus(statusEl, 'open', 'streaming…');
|
||||||
|
// On connection, fetch the latest snapshot metadata so the UI can show
|
||||||
|
// the freshest overlay and CSV (prevents showing an old image cached in index.html).
|
||||||
|
if (control?.snapshotOutEl) {
|
||||||
|
fetch('/snapshots/latest', { cache: 'no-store' })
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(new Error('no snapshot')))
|
||||||
|
.then((j) => {
|
||||||
|
if (!j?.ok) return;
|
||||||
|
const overlayPNG = (j.overlay ? `${j.overlay}?_t=${Date.now()}` : null);
|
||||||
|
const overlayCSV = (j.overlayCSV ? `${j.overlayCSV}?_t=${Date.now()}` : null);
|
||||||
|
if (overlayPNG) {
|
||||||
|
document.getElementById('overlayImg').src = overlayPNG;
|
||||||
|
}
|
||||||
|
if (window.readCSV && overlayCSV) {
|
||||||
|
window.readCSV.renderCSV('csvTable', overlayCSV);
|
||||||
|
}
|
||||||
|
// Show links to the latest snapshot in the snapshot output box (non-intrusive)
|
||||||
|
control.snapshotOutEl.innerHTML = `Latest snapshot: <a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`;
|
||||||
|
}).catch(() => {
|
||||||
|
// ignore — no latest snapshot yet
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = async (evt) => {
|
||||||
|
if (typeof evt.data === 'string') {
|
||||||
|
// Control/meta message
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
console.warn('Server error:', msg.error);
|
||||||
|
prettyStatus(statusEl, 'error', msg.error);
|
||||||
|
}
|
||||||
|
if (msg.type === 'snapshot' && control?.snapshotOutEl) {
|
||||||
|
if (msg.ok && msg.url && msg.urlApp) {
|
||||||
|
// Use overlay URL and CSV provided by the server (fallback to expected names)
|
||||||
|
const overlayPNG = msg.overlay || msg.urlApp.replace('_annotated.jpg','_two_cam_overlay.png');
|
||||||
|
// Prefer server-provided overlayCSV. If not present, derive from the original jpg name
|
||||||
|
const overlayCSV = msg.overlayCSV || msg.url.replace('.jpg','_two_cam.csv');
|
||||||
|
|
||||||
|
control.snapshotOutEl.innerHTML =
|
||||||
|
`Snapshot: <a href="${msg.url}" target="_blank" rel="noopener">${msg.url}</a></br></br> ` +
|
||||||
|
` Recognized: <a href="${msg.urlApp}" target="_blank" rel="noopener">${msg.urlApp}</a></br> ` +
|
||||||
|
` Overlay: <a href="${overlayPNG}" target="_blank" rel="noopener">PNG Overlay</a>`;
|
||||||
|
// Update overlay immediately — server now ensures files are ready before responding
|
||||||
|
document.getElementById('overlayImg').src = overlayPNG;
|
||||||
|
// Render CSV values into the csvTable container (if available)
|
||||||
|
if (window.readCSV) {
|
||||||
|
window.readCSV.renderCSV('csvTable', overlayCSV);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
control.snapshotOutEl.textContent = 'Snapshot failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore non-JSON text frames
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Binary JPEG frame
|
||||||
|
const blob = evt.data; // Blob of image/jpeg
|
||||||
|
try {
|
||||||
|
const bmp = await blobToBitmap(blob);
|
||||||
|
renderer.enqueue(bmp);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Bitmap decode failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
prettyStatus(statusEl, 'error', 'network error');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
renderer.stop();
|
||||||
|
prettyStatus(statusEl, 'closed', closedOnPurpose ? 'by client' : 'retrying…');
|
||||||
|
if (!closedOnPurpose) {
|
||||||
|
setTimeout(connect, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// Attach controls if provided (video0)
|
||||||
|
if (control) {
|
||||||
|
const { resSelect, fpsSelect, qSelect, applyBtn, snapshotBtn, startBtn, stopBtn, snapshotOutEl } = control;
|
||||||
|
|
||||||
|
applyBtn?.addEventListener('click', () => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const wh = parseWH(resSelect?.value);
|
||||||
|
const fps = Number(fpsSelect?.value || 15);
|
||||||
|
const q = Number(qSelect?.value || 5);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'control',
|
||||||
|
action: 'setParams',
|
||||||
|
params: {
|
||||||
|
...(wh || {}),
|
||||||
|
fps,
|
||||||
|
quality: q
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
snapshotBtn?.addEventListener('click', () => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
snapshotOutEl.textContent = 'Taking snapshot…';
|
||||||
|
ws.send(JSON.stringify({ type: 'control', action: 'snapshot' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
startBtn?.addEventListener('click', () => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: 'control', action: 'start' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
stopBtn?.addEventListener('click', () => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
ws.send(JSON.stringify({ type: 'control', action: 'stop' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: () => { closedOnPurpose = true; ws?.close(); renderer.stop(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VideoService = {
|
||||||
|
attachStream
|
||||||
|
};
|
||||||
|
})();
|
||||||
283
server.js
Executable file
283
server.js
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
// server.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const compression = require('compression');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const { FFmpegStreamer } = require('./programs/videoServer');
|
||||||
|
const { pickDevices } = require('./programs/input');
|
||||||
|
const driverWS = require("./programs/driver");
|
||||||
|
const screenShot = require("./programs/screenShot")
|
||||||
|
|
||||||
|
const {logHttpRequest, logTcpConnection, logHttpUpgrade, logWssConnected,
|
||||||
|
logWssClosed, connected, connectionLost} = require('./programs/log');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8443);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
// --- TLS ---
|
||||||
|
const TLS_DIR = path.resolve(__dirname, 'https');
|
||||||
|
const serverOptions = {
|
||||||
|
key: fs.readFileSync(path.join(TLS_DIR, 'server.key')),
|
||||||
|
cert: fs.readFileSync(path.join(TLS_DIR, 'server.crt')),
|
||||||
|
...(fs.existsSync(path.join(TLS_DIR, 'dhparam.pem'))
|
||||||
|
? { dhparam: fs.readFileSync(path.join(TLS_DIR, 'dhparam.pem')) }
|
||||||
|
: {}),
|
||||||
|
requestCert: false,
|
||||||
|
rejectUnauthorized: false, // dev-friendly for self-signed
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Express + CSP ---
|
||||||
|
const app = express();
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.use(compression());
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
useDefaults: true,
|
||||||
|
directives: {
|
||||||
|
"default-src": ["'self'"],
|
||||||
|
"script-src": ["'self'"], // no inline JS
|
||||||
|
"style-src": ["'self'", "'unsafe-inline'"],
|
||||||
|
"img-src": ["'self'", "data:"],
|
||||||
|
"connect-src": ["'self'"], // same-origin WSS
|
||||||
|
"object-src": ["'none'"],
|
||||||
|
"base-uri": ["'self'"],
|
||||||
|
"frame-ancestors": ["'self'"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.static(path.join(__dirname, 'public'), { etag: true, maxAge: '1h' }));
|
||||||
|
app.get('/health', (_req, res) => res.status(200).send('ok'));
|
||||||
|
|
||||||
|
// --- HTTPS server ---
|
||||||
|
const server = https.createServer(serverOptions, app);
|
||||||
|
|
||||||
|
// Track sockets so shutdown is clean
|
||||||
|
const activeHttpSockets = new Set();
|
||||||
|
server.on('connection', (socket) => {
|
||||||
|
activeHttpSockets.add(socket);
|
||||||
|
socket.on('close', () => activeHttpSockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket server ---
|
||||||
|
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: false });
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
function installHeartbeat(ws) {
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
}
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (!ws.isAlive) return ws.terminate();
|
||||||
|
ws.isAlive = false;
|
||||||
|
try { ws.ping(); } catch {}
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Upgrade routing
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
try { socket.setNoDelay(true); } catch {}
|
||||||
|
const { url } = request;
|
||||||
|
if (!url || !url.startsWith('/ws/')) return socket.destroy();
|
||||||
|
|
||||||
|
|
||||||
|
if (url === '/ws/robot') {
|
||||||
|
console.log('Robot requested');
|
||||||
|
wssInput.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
ws.upgradePath = url;
|
||||||
|
wssInput.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
ws.upgradePath = url;
|
||||||
|
wss.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Streams ---
|
||||||
|
const [DEV0, DEV1] = pickDevices(process.env);
|
||||||
|
console.log(`[DEV] Using devices: ${DEV0} (video0), ${DEV1} (video1)`);
|
||||||
|
|
||||||
|
// Cam0: MJPEG pass-through if available (lowest latency)
|
||||||
|
const cam0 = new FFmpegStreamer(DEV0, {
|
||||||
|
name: 'video0',
|
||||||
|
fps: 30,
|
||||||
|
quality: 8, // 5 wäre besser
|
||||||
|
input: {
|
||||||
|
format: 'mjpeg',
|
||||||
|
fps: 30,
|
||||||
|
//size: '640x480',
|
||||||
|
//size: '1280x720',
|
||||||
|
size: '1280x960',
|
||||||
|
useWallclock: true,
|
||||||
|
threadQueueSize: 64,
|
||||||
|
channel: 0,
|
||||||
|
},
|
||||||
|
tryFormats: ['mjpeg', 'yuyv422', 'rgb24'],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Cam1: your working timing on /dev/video2; let driver pick format first
|
||||||
|
const cam1 = new FFmpegStreamer(DEV1, {
|
||||||
|
name: 'video1',
|
||||||
|
fps: 30,
|
||||||
|
quality: 8, // 5 wäre besser
|
||||||
|
input: {
|
||||||
|
format: 'mjpeg', // driver decides
|
||||||
|
fps: 30,
|
||||||
|
//size: '640x480',
|
||||||
|
//size: '1280x720',
|
||||||
|
size: '1280x960',
|
||||||
|
useWallclock: true,
|
||||||
|
threadQueueSize: 64,
|
||||||
|
channel: 0,
|
||||||
|
},
|
||||||
|
tryFormats: ['mjpeg', 'yuyv422', 'rgb24'],
|
||||||
|
});
|
||||||
|
|
||||||
|
cam0.start();
|
||||||
|
cam1.start();
|
||||||
|
|
||||||
|
|
||||||
|
const wssInput = new WebSocket.Server({ noServer: true });
|
||||||
|
|
||||||
|
|
||||||
|
// WS connections
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
logWssConnected(req);
|
||||||
|
installHeartbeat(ws);
|
||||||
|
const pathName = ws.upgradePath || req.url || '';
|
||||||
|
const controlEnabled = pathName === '/ws/video0';
|
||||||
|
|
||||||
|
if (pathName === '/ws/video0') cam0.attach(ws);
|
||||||
|
else if (pathName === '/ws/video1') cam1.attach(ws);
|
||||||
|
else return ws.close(1008, 'Unknown WS path');
|
||||||
|
|
||||||
|
// If the streamer isn't running when a client connects, try to start it.
|
||||||
|
// This makes the system more resilient to transient ffmpeg failures.
|
||||||
|
try {
|
||||||
|
if (pathName === '/ws/video0' && !cam0.running) {
|
||||||
|
console.log('[WS] client connected to /ws/video0 — starting cam0');
|
||||||
|
cam0.start();
|
||||||
|
}
|
||||||
|
if (pathName === '/ws/video1' && !cam1.running) {
|
||||||
|
console.log('[WS] client connected to /ws/video1 — starting cam1');
|
||||||
|
cam1.start();
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[WS] failed to start streamer:', err?.message || err); }
|
||||||
|
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (isBinary || !controlEnabled) return;
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(data.toString()); }
|
||||||
|
catch { return ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' })); }
|
||||||
|
handleControlMessage(ws, msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'hello',
|
||||||
|
path: pathName,
|
||||||
|
control: controlEnabled,
|
||||||
|
camera: pathName.endsWith('video0') ? 'video0' : 'video1',
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => { logWssClosed(req, code, reason); });
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("upgrade", (req, socket, head) => {
|
||||||
|
if (req.url === "/ws/robot") {
|
||||||
|
console.log("Robot requested");
|
||||||
|
wssInput.handleUpgrade(req, socket, head, (ws) => { wssInput.emit("connection", ws, req); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const targetServer = process.env.TARGET_SERVER || 'wss://localhost:2095';
|
||||||
|
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||||
|
driverWS.setupCommandForwarding(wssInput, targetServer);
|
||||||
|
|
||||||
|
|
||||||
|
// Control channel (video0 only)
|
||||||
|
function handleControlMessage(ws, msg) {
|
||||||
|
if (msg?.type !== 'control') {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Unsupported message type' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'snapshot': {
|
||||||
|
|
||||||
|
const outDir = path.join(__dirname, 'public', 'snapshots');
|
||||||
|
screenShot.snapshot(outDir, cam0, cam1, ws);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setParams': {
|
||||||
|
const p = msg.params || {};
|
||||||
|
cam0.restart({
|
||||||
|
...(Number(p.width) ? { width: Number(p.width) } : { width: undefined }),
|
||||||
|
...(Number(p.height) ? { height: Number(p.height) } : { height: undefined }),
|
||||||
|
...(Number(p.fps) ? { fps: Number(p.fps) } : {}),
|
||||||
|
...(Number(p.quality) ? { quality: Number(p.quality) } : {}),
|
||||||
|
...(p.input ? { input: { ...cam0.opts.input, ...p.input } } : {}),
|
||||||
|
});
|
||||||
|
ws.send(JSON.stringify({ type: 'ack', action: 'setParams', params: { ...cam0.opts } }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'start': cam0.start(); ws.send(JSON.stringify({ type: 'ack', action: 'start' })); break;
|
||||||
|
case 'stop': cam0.stop(); ws.send(JSON.stringify({ type: 'ack', action: 'stop' })); break;
|
||||||
|
default:
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: `Unknown action: ${msg.action}` }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`HTTPS server listening on https://${HOST}:${PORT}`);
|
||||||
|
console.log(`Open https://localhost:${PORT} (accept the self-signed certificate in dev)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function shutdown(code = 0) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log('\nShutting down…');
|
||||||
|
|
||||||
|
try { clearInterval(pingInterval); } catch {}
|
||||||
|
|
||||||
|
try { cam0.stop(); } catch {}
|
||||||
|
try { cam1.stop(); } catch {}
|
||||||
|
|
||||||
|
try { wss.clients.forEach(ws => { try { ws.terminate(); } catch {} }); } catch {}
|
||||||
|
try { await new Promise(res => wss.close(() => res())); } catch {}
|
||||||
|
|
||||||
|
try { for (const s of activeHttpSockets) { try { s.destroy(); } catch {} } } catch {}
|
||||||
|
try { await new Promise(res => server.close(() => res())); } catch {}
|
||||||
|
|
||||||
|
setTimeout(() => { console.warn('Force exiting…'); process.exit(code); }, 1500).unref();
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
process.once('SIGINT', () => shutdown(0));
|
||||||
|
process.once('SIGTERM', () => shutdown(0));
|
||||||
|
process.once('uncaughtException', (e) => { console.error(e); shutdown(1); });
|
||||||
|
process.once('unhandledRejection', (r) => { console.error(r); shutdown(1); });
|
||||||
@@ -1 +0,0 @@
|
|||||||
alert("abc");
|
|
||||||
Reference in New Issue
Block a user