Fits small cameras
This commit is contained in:
1 parent
a773166c91
commit
98ed2576c6
7 files changed
+526
-96
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
scan-calibrator/
|
scan-calibrator/
|
||||||
├── README.md # 交互式向导主文档
|
├── README.md # 交互式向导主文档
|
||||||
├── 手动配置README.md # 完整手动流程和参数原理
|
├── 手动配置README.md # 完整手动流程和参数原理
|
||||||
|
├── install.sh # 首次部署时安装依赖并做完整环境自检
|
||||||
├── interactive.sh # 交互式向导入口
|
├── interactive.sh # 交互式向导入口
|
||||||
├── interactive_calibrator.py
|
├── interactive_calibrator.py
|
||||||
├── calibrate.sh # 手动配置入口
|
├── calibrate.sh # 手动配置入口
|
||||||
@@ -24,11 +25,22 @@ scan-calibrator/
|
|||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
|
首次把本目录复制到一台RK3588后,先执行一次安装与自检:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/zhangw/scan-calibrator
|
cd /home/zhangw/scan-calibrator
|
||||||
|
./install.sh
|
||||||
./interactive.sh
|
./interactive.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`install.sh`可重复运行,不会修改scan参数。它会在缺少时通过`apt-get`安装宿主机
|
||||||
|
`python3`和`imagemagick`,然后检查Docker、smart-cam容器、scan HTTP服务、数据目录写权限、
|
||||||
|
容器内C++/OpenCV编译环境以及拼图命令。非root账号安装缺失软件时需要具有`sudo`权限。
|
||||||
|
|
||||||
|
如果设备上曾出现“未找到ImageMagick identify”,就是宿主机缺少`imagemagick`;
|
||||||
|
运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内,
|
||||||
|
因为验证拼图程序运行在宿主机。
|
||||||
|
|
||||||
只需要输入:
|
只需要输入:
|
||||||
|
|
||||||
- `1`:确认、执行或选择野外部署;
|
- `1`:确认、执行或选择野外部署;
|
||||||
@@ -133,7 +145,8 @@ cd /home/zhangw/scan-calibrator
|
|||||||
- 回复`1`:使用`hanging`,表示球机在野外正常倒挂;
|
- 回复`1`:使用`hanging`,表示球机在野外正常倒挂;
|
||||||
- 回复`2`:使用`desktop`,表示办公室桌面正放且摄像头后台旋转了180度。
|
- 回复`2`:使用`desktop`,表示办公室桌面正放且摄像头后台旋转了180度。
|
||||||
|
|
||||||
它决定角点的pan方向和生成配置时的扫描顺序,等价于最后执行:
|
它决定验证拼图时的画面方向。摄像头的pan数值递增/递减方向不再由安装姿态写死,
|
||||||
|
而是在第④步根据实拍画面位移自动识别。生成配置仍等价于最后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./calibrate.sh generate --mount hanging
|
./calibrate.sh generate --mount hanging
|
||||||
@@ -173,11 +186,15 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
|
|||||||
2. 沿pan方向尝试若干候选角度,每次抓图后回到起点;
|
2. 沿pan方向尝试若干候选角度,每次抓图后回到起点;
|
||||||
3. 用ORB特征、RANSAC和单轴画面位移求水平视场;
|
3. 用ORB特征、RANSAC和单轴画面位移求水平视场;
|
||||||
4. 沿tilt方向重复测量;
|
4. 沿tilt方向重复测量;
|
||||||
5. 每个方向用不同移动量反算视场:两组结果只有在特征质量高且视场差不超过8%时才接受,
|
5. 每个方向会跑完预设的全部候选step,再统一筛选相互一致的视场样本;不因前几个样本
|
||||||
否则要求至少三组一致结果;
|
离散度低而提前停止;
|
||||||
6. 水平方向按1%目标重叠计算step,垂直方向按4%目标重叠计算step;
|
6. 两组结果只有在特征质量高且视场差不超过8%时才接受,否则要求至少三组一致结果;
|
||||||
7. 要求至少50个几何内点且内点率至少45%,否则拒绝结果;
|
7. 水平方向按4%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿;
|
||||||
8. 删除本次自动标定产生的临时截图。
|
8. 默认使用ORB特征和RANSAC几何变换;当局部图案重复导致几何内点不足时,自动使用整幅图
|
||||||
|
相位相关复核纯单轴位移,相位响应不得低于0.20;
|
||||||
|
8. 要求至少50个几何内点且内点率至少45%,否则拒绝结果;
|
||||||
|
9. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减;
|
||||||
|
10. 删除本次自动标定产生的临时截图。
|
||||||
|
|
||||||
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面
|
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面
|
||||||
覆盖多少角度。
|
覆盖多少角度。
|
||||||
@@ -243,6 +260,14 @@ docker exec smart-cam curl -fsS \
|
|||||||
|
|
||||||
扫描完成或失败后自动停止日志跟随,并显示结果目录。
|
扫描完成或失败后自动停止日志跟随,并显示结果目录。
|
||||||
|
|
||||||
|
扫描过程中需要中断时,直接按`Ctrl+C`。向导会主动调用停止接口并结束本次扫描,
|
||||||
|
已经保存的小图和`meta.json`会保留。也可以在另一个终端执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec smart-cam curl -fsS -X POST \
|
||||||
|
http://127.0.0.1:18080/api/v1/scan/stop
|
||||||
|
```
|
||||||
|
|
||||||
### ⑧ 拼图测试
|
### ⑧ 拼图测试
|
||||||
|
|
||||||
只有本次试扫成功完成后才会出现这个问题。回复`1`后,向导将本次扫描的结果目录和第①步
|
只有本次试扫成功完成后才会出现这个问题。回复`1`后,向导将本次扫描的结果目录和第①步
|
||||||
@@ -250,9 +275,13 @@ docker exec smart-cam curl -fsS \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
/home/zhangw/scan-calibrator/scan-panorama/stitch.sh \
|
/home/zhangw/scan-calibrator/scan-panorama/stitch.sh \
|
||||||
<本次扫描目录> --mount <desktop或hanging>
|
<本次扫描目录> --mount <desktop或hanging> \
|
||||||
|
--pan-direction <increasing或decreasing>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`mount`只决定上下行顺序;左右列顺序使用第④步实拍得到的pan方向。交互式向导会自动传入,
|
||||||
|
不需要现场人员额外选择。
|
||||||
|
|
||||||
拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。
|
拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。
|
||||||
|
|
||||||
拼图结果统一保存在:
|
拼图结果统一保存在:
|
||||||
@@ -267,8 +296,9 @@ docker exec smart-cam curl -fsS \
|
|||||||
|
|
||||||
- 每一个摄像头点位都要重新记录角点;
|
- 每一个摄像头点位都要重新记录角点;
|
||||||
- 摄像头型号或zoom变化时必须重新自动计算step;
|
- 摄像头型号或zoom变化时必须重新自动计算step;
|
||||||
|
- 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
|
||||||
|
或“默认走短弧”代替;
|
||||||
- 标定期间不要同时从Web界面控制PTZ;
|
- 标定期间不要同时从Web界面控制PTZ;
|
||||||
- 水平目标重叠为1%,垂直目标重叠为4%,都不超过5%上限;
|
- 向导按轴设置目标重叠:水平4%、垂直2%;最终以完整试扫的接缝结果为准;
|
||||||
- 自动标定不会使用单个样本;两组样本必须同时满足高特征质量和视场差不超过8%,
|
- 自动标定会完整测试全部候选step,再从所有可靠结果中筛选一致样本;
|
||||||
普通质量的结果必须由至少三组样本互相验证;
|
|
||||||
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
|
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONTAINER_NAME="${SCAN_CALIBRATOR_CONTAINER:-smart-cam}"
|
||||||
|
SCAN_STATUS_URL="http://127.0.0.1:18080/api/v1/scan/status"
|
||||||
|
HELPER_SOURCE="$ROOT_DIR/src/measure_overlap.cpp"
|
||||||
|
HELPER_CONTAINER_SOURCE="/tmp/scan-calibrator-measure.cpp"
|
||||||
|
HELPER_CONTAINER_BIN="/tmp/scan-calibrator-measure"
|
||||||
|
|
||||||
|
info() {
|
||||||
|
printf '[检查] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ok() {
|
||||||
|
printf '[通过] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[失败] %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_host_packages() {
|
||||||
|
local -a packages=("$@")
|
||||||
|
local -a elevate=()
|
||||||
|
|
||||||
|
((${#packages[@]} > 0)) || return 0
|
||||||
|
command -v apt-get >/dev/null 2>&1 ||
|
||||||
|
fail "缺少 ${packages[*]},且系统没有apt-get,需先按当前发行版手动安装"
|
||||||
|
|
||||||
|
if ((EUID != 0)); then
|
||||||
|
command -v sudo >/dev/null 2>&1 ||
|
||||||
|
fail "安装 ${packages[*]} 需要root权限;请用root运行本脚本"
|
||||||
|
elevate=(sudo)
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "安装宿主机依赖:${packages[*]}"
|
||||||
|
"${elevate[@]}" apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive "${elevate[@]}" apt-get install -y --no-install-recommends \
|
||||||
|
"${packages[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
printf 'scan-calibrator 安装与运行环境检查\n\n'
|
||||||
|
|
||||||
|
local -a missing_packages=()
|
||||||
|
command -v python3 >/dev/null 2>&1 || missing_packages+=(python3)
|
||||||
|
if ! command -v identify >/dev/null 2>&1 ||
|
||||||
|
! command -v montage >/dev/null 2>&1; then
|
||||||
|
missing_packages+=(imagemagick)
|
||||||
|
fi
|
||||||
|
install_host_packages "${missing_packages[@]}"
|
||||||
|
|
||||||
|
info "检查宿主机命令"
|
||||||
|
command -v bash >/dev/null 2>&1 || fail "未找到bash"
|
||||||
|
command -v python3 >/dev/null 2>&1 || fail "未找到python3"
|
||||||
|
command -v identify >/dev/null 2>&1 || fail "未找到ImageMagick identify"
|
||||||
|
command -v montage >/dev/null 2>&1 || fail "未找到ImageMagick montage"
|
||||||
|
command -v docker >/dev/null 2>&1 || fail "未找到docker;请先部署smart-cam运行环境"
|
||||||
|
docker info >/dev/null 2>&1 ||
|
||||||
|
fail "无法访问Docker daemon;请用有Docker权限的账号运行"
|
||||||
|
ok "Python、Docker和ImageMagick命令可用"
|
||||||
|
|
||||||
|
info "检查项目文件和Python语法"
|
||||||
|
[[ -f "$HELPER_SOURCE" ]] || fail "缺少 $HELPER_SOURCE"
|
||||||
|
[[ -f "$ROOT_DIR/interactive_calibrator.py" ]] ||
|
||||||
|
fail "缺少interactive_calibrator.py"
|
||||||
|
[[ -f "$ROOT_DIR/scan_calibrator.py" ]] || fail "缺少scan_calibrator.py"
|
||||||
|
[[ -f "$ROOT_DIR/scan-panorama/stitch_scan.py" ]] ||
|
||||||
|
fail "缺少scan-panorama/stitch_scan.py"
|
||||||
|
python3 -B -c '
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
for name in sys.argv[1:]:
|
||||||
|
path = pathlib.Path(name)
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
' \
|
||||||
|
"$ROOT_DIR/interactive_calibrator.py" \
|
||||||
|
"$ROOT_DIR/scan_calibrator.py" \
|
||||||
|
"$ROOT_DIR/scan-panorama/stitch_scan.py"
|
||||||
|
chmod +x \
|
||||||
|
"$ROOT_DIR/install.sh" \
|
||||||
|
"$ROOT_DIR/interactive.sh" \
|
||||||
|
"$ROOT_DIR/calibrate.sh" \
|
||||||
|
"$ROOT_DIR/scan-panorama/stitch.sh"
|
||||||
|
mkdir -p "$ROOT_DIR/result" "$ROOT_DIR/scan-panorama/result"
|
||||||
|
[[ -w "$ROOT_DIR/result" && -w "$ROOT_DIR/scan-panorama/result" ]] ||
|
||||||
|
fail "当前账号不能写入result目录"
|
||||||
|
ok "程序文件完整且语法检查通过"
|
||||||
|
|
||||||
|
info "检查smart-cam容器"
|
||||||
|
[[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]] ||
|
||||||
|
fail "容器 $CONTAINER_NAME 未运行"
|
||||||
|
docker exec "$CONTAINER_NAME" sh -lc '
|
||||||
|
command -v curl >/dev/null &&
|
||||||
|
command -v python3 >/dev/null &&
|
||||||
|
command -v g++ >/dev/null &&
|
||||||
|
command -v pkg-config >/dev/null &&
|
||||||
|
pkg-config --exists opencv4 &&
|
||||||
|
test -f /smart-cam/config/config.json &&
|
||||||
|
test -d /smart-cam/data/scan &&
|
||||||
|
test -w /smart-cam/data/scan
|
||||||
|
' || fail "容器内缺少curl/python3/g++/OpenCV,或scan数据目录不可写"
|
||||||
|
ok "容器内编译环境、配置文件和scan数据目录正常"
|
||||||
|
|
||||||
|
info "编译并运行标定图像分析器自检"
|
||||||
|
docker cp "$HELPER_SOURCE" "$CONTAINER_NAME:$HELPER_CONTAINER_SOURCE" >/dev/null
|
||||||
|
docker exec "$CONTAINER_NAME" sh -lc \
|
||||||
|
"g++ -O2 -std=c++17 '$HELPER_CONTAINER_SOURCE' -o '$HELPER_CONTAINER_BIN' \
|
||||||
|
\$(pkg-config --cflags --libs opencv4)"
|
||||||
|
docker exec "$CONTAINER_NAME" test -x "$HELPER_CONTAINER_BIN" ||
|
||||||
|
fail "标定图像分析器编译失败"
|
||||||
|
ok "OpenCV标定图像分析器可以编译"
|
||||||
|
|
||||||
|
info "检查scan HTTP服务"
|
||||||
|
local status
|
||||||
|
status="$(docker exec "$CONTAINER_NAME" curl -fsS --max-time 10 "$SCAN_STATUS_URL")" ||
|
||||||
|
fail "scan HTTP服务不可用:$SCAN_STATUS_URL"
|
||||||
|
python3 -c '
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload.get("scan"), dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
' <<<"$status" || fail "scan状态接口没有返回合法的scan JSON"
|
||||||
|
ok "scan HTTP服务正常"
|
||||||
|
|
||||||
|
info "检查ImageMagick拼图命令"
|
||||||
|
identify -version >/dev/null
|
||||||
|
montage -version >/dev/null
|
||||||
|
ok "ImageMagick identify/montage正常"
|
||||||
|
|
||||||
|
printf '\n全部检查通过。现在可以运行:\n cd %s\n ./interactive.sh\n' "$ROOT_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+224
-61
@@ -10,6 +10,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -20,7 +21,11 @@ import scan_calibrator as core
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
CONFIG_PATH = "/smart-cam/config/config.json"
|
CONFIG_PATH = "/smart-cam/config/config.json"
|
||||||
TARGET_OVERLAPS = {"pan": 0.01, "tilt": 0.04}
|
# The grid contact sheet is the final calibration check. Horizontal seams on
|
||||||
|
# the tested PTZ camera retain more real-world overlap than the center-shift
|
||||||
|
# model predicts, while vertical seams need a small safety margin. Keep these
|
||||||
|
# axis-specific targets within the generator's accepted 0%..5% range.
|
||||||
|
TARGET_OVERLAPS = {"pan": 0.04, "tilt": 0.02}
|
||||||
RED = "\033[31m"
|
RED = "\033[31m"
|
||||||
GREEN = "\033[32m"
|
GREEN = "\033[32m"
|
||||||
YELLOW = "\033[33m"
|
YELLOW = "\033[33m"
|
||||||
@@ -80,39 +85,10 @@ def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int:
|
|||||||
return 1 if high - origin["tilt"] >= origin["tilt"] - low else -1
|
return 1 if high - origin["tilt"] >= origin["tilt"] - low else -1
|
||||||
|
|
||||||
|
|
||||||
def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
def consistent_samples(reliable: list[tuple[int, float, float, dict]]) -> list:
|
||||||
snapshots: set[str]) -> dict:
|
"""Return the largest group that estimates one mutually consistent FOV."""
|
||||||
direction = choose_direction(axis, origin, points)
|
if not reliable:
|
||||||
baseline = core.snapshot()
|
return []
|
||||||
snapshots.add(baseline)
|
|
||||||
reliable: list[tuple[int, float, float, dict]] = []
|
|
||||||
say(f"\n正在标定 {axis}_step,摄像头会自动移动并回到起点……")
|
|
||||||
|
|
||||||
for delta in candidate_deltas(axis):
|
|
||||||
result = core.measure_delta(
|
|
||||||
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
|
||||||
)
|
|
||||||
snapshots.add(str(result.get("candidate_file", "")))
|
|
||||||
if not result.get("valid"):
|
|
||||||
say(f" step={delta}: 当前图片匹配不足,继续尝试")
|
|
||||||
continue
|
|
||||||
overlap = float(result["overlap"])
|
|
||||||
inliers = int(result.get("inliers") or 0)
|
|
||||||
ratio = float(result.get("inlier_ratio") or 0)
|
|
||||||
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,"
|
|
||||||
f"有效特征={inliers},内点率={ratio*100:.1f}%")
|
|
||||||
if inliers >= 50 and ratio >= 0.45 and 0.08 <= overlap <= 0.80:
|
|
||||||
reliable.append((delta, overlap, delta / (1.0 - overlap), result))
|
|
||||||
|
|
||||||
if len(reliable) < 2:
|
|
||||||
core.move(origin)
|
|
||||||
core.fail(
|
|
||||||
f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。"
|
|
||||||
"请把摄像头移到远处、纹理丰富、非重复图案的位置后重新运行向导"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fixed OSD text and repeated scenery can yield a convincing false
|
|
||||||
# homography. A real optical FOV must agree across different PTZ deltas.
|
|
||||||
clusters = [
|
clusters = [
|
||||||
[
|
[
|
||||||
sample for sample in reliable
|
sample for sample in reliable
|
||||||
@@ -120,9 +96,124 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
]
|
]
|
||||||
for seed in reliable
|
for seed in reliable
|
||||||
]
|
]
|
||||||
consistent = max(
|
return max(
|
||||||
clusters, key=lambda cluster: (len(cluster), -max(x[2] for x in cluster))
|
clusters, key=lambda cluster: (len(cluster), -max(x[2] for x in cluster))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fov_statistics(samples: list[tuple[int, float, float, dict]]) -> tuple[float, float]:
|
||||||
|
fovs = sorted(sample[2] for sample in samples)
|
||||||
|
estimated_fov = (
|
||||||
|
fovs[len(fovs) // 2] if len(fovs) % 2
|
||||||
|
else (fovs[len(fovs) // 2 - 1] + fovs[len(fovs) // 2]) / 2
|
||||||
|
)
|
||||||
|
return estimated_fov, (max(fovs) - min(fovs)) / estimated_fov
|
||||||
|
|
||||||
|
|
||||||
|
def failure_detail(result: dict) -> str:
|
||||||
|
reason = str(result.get("reason") or "unknown")
|
||||||
|
labels = {
|
||||||
|
"ptz_motion_shortfall": "云台受限,实际移动量不足",
|
||||||
|
"not_enough_features": "两张图片可检测特征不足",
|
||||||
|
"not_enough_matches": "两张图片的公共特征不足",
|
||||||
|
"homography_failed": "公共特征无法形成可靠几何变换",
|
||||||
|
"axis_motion_too_small": "匹配到的主轴画面位移过小",
|
||||||
|
"axis_motion_inconsistent": "匹配位移方向异常或串轴过大",
|
||||||
|
}
|
||||||
|
fields = []
|
||||||
|
if result.get("actual_axis_motion") is not None:
|
||||||
|
fields.append(
|
||||||
|
f"实际移动={result['actual_axis_motion']}/"
|
||||||
|
f"{result.get('requested_axis_motion', result.get('delta', '?'))}"
|
||||||
|
)
|
||||||
|
if result.get("good_matches") is not None:
|
||||||
|
fields.append(f"公共特征={result['good_matches']}")
|
||||||
|
if result.get("inliers") is not None:
|
||||||
|
fields.append(f"几何内点={result['inliers']}")
|
||||||
|
if result.get("axis_shift") is not None:
|
||||||
|
fields.append(f"主轴位移={float(result['axis_shift'])*100:.1f}%")
|
||||||
|
if result.get("cross_axis_shift") is not None:
|
||||||
|
fields.append(f"串轴位移={float(result['cross_axis_shift'])*100:.1f}%")
|
||||||
|
if result.get("phase_response") is not None:
|
||||||
|
fields.append(f"相位响应={float(result['phase_response']):.2f}")
|
||||||
|
suffix = f"({','.join(fields)})" if fields else ""
|
||||||
|
return f"{labels.get(reason, reason)}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||||
|
snapshots: set[str]) -> dict:
|
||||||
|
direction = choose_direction(axis, origin, points)
|
||||||
|
baseline = core.snapshot()
|
||||||
|
snapshots.add(baseline)
|
||||||
|
reliable: list[tuple[int, float, float, dict]] = []
|
||||||
|
failure_reasons: dict[str, int] = {}
|
||||||
|
say(f"\n正在标定 {axis}_step,摄像头会自动移动并回到起点……")
|
||||||
|
|
||||||
|
for delta in candidate_deltas(axis):
|
||||||
|
result = core.measure_delta(
|
||||||
|
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||||||
|
)
|
||||||
|
snapshots.add(str(result.get("candidate_file", "")))
|
||||||
|
if (
|
||||||
|
axis == "tilt"
|
||||||
|
and not reliable
|
||||||
|
and result.get("reason") == "ptz_motion_shortfall"
|
||||||
|
):
|
||||||
|
direction *= -1
|
||||||
|
say(
|
||||||
|
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定"
|
||||||
|
)
|
||||||
|
result = core.measure_delta(
|
||||||
|
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||||||
|
)
|
||||||
|
snapshots.add(str(result.get("candidate_file", "")))
|
||||||
|
if not result.get("valid"):
|
||||||
|
reason = str(result.get("reason") or "unknown")
|
||||||
|
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
|
||||||
|
say(f" step={delta}: {failure_detail(result)},继续尝试")
|
||||||
|
continue
|
||||||
|
overlap = float(result["overlap"])
|
||||||
|
inliers = int(result.get("inliers") or 0)
|
||||||
|
ratio = float(result.get("inlier_ratio") or 0)
|
||||||
|
if result.get("match_method") == "phase_correlation":
|
||||||
|
quality = f"整图相位响应={float(result.get('phase_response') or 0):.2f}"
|
||||||
|
else:
|
||||||
|
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
|
||||||
|
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}")
|
||||||
|
feature_reliable = inliers >= 50 and ratio >= 0.45
|
||||||
|
phase_reliable = (
|
||||||
|
result.get("match_method") == "phase_correlation"
|
||||||
|
and float(result.get("phase_response") or 0) >= core.PHASE_MIN_RESPONSE
|
||||||
|
)
|
||||||
|
if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.80:
|
||||||
|
reliable.append((delta, overlap, delta / (1.0 - overlap), result))
|
||||||
|
if len(reliable) >= 4:
|
||||||
|
provisional = consistent_samples(reliable)
|
||||||
|
if len(provisional) >= 4:
|
||||||
|
_, provisional_spread = fov_statistics(provisional)
|
||||||
|
if provisional_spread <= 0.08 and min(
|
||||||
|
sample[1] for sample in provisional
|
||||||
|
) <= 0.55:
|
||||||
|
say(
|
||||||
|
f" 已获得{len(provisional)}个覆盖充分且一致的样本,"
|
||||||
|
"停止测试更大的step"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(reliable) < 2:
|
||||||
|
core.move(origin)
|
||||||
|
summary = "、".join(
|
||||||
|
f"{reason}={count}次"
|
||||||
|
for reason, count in sorted(failure_reasons.items())
|
||||||
|
)
|
||||||
|
core.fail(
|
||||||
|
f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。"
|
||||||
|
f"失败统计:{summary or '无'}。请根据上方每个step的具体原因处理后重试"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fixed OSD text and repeated scenery can yield a convincing false
|
||||||
|
# homography. A real optical FOV must agree across different PTZ deltas.
|
||||||
|
consistent = consistent_samples(reliable)
|
||||||
if len(consistent) < 2:
|
if len(consistent) < 2:
|
||||||
core.move(origin)
|
core.move(origin)
|
||||||
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
|
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
|
||||||
@@ -131,16 +222,17 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
"本次结果不写入配置,请更换标定画面"
|
"本次结果不写入配置,请更换标定画面"
|
||||||
)
|
)
|
||||||
|
|
||||||
fovs = sorted(sample[2] for sample in consistent)
|
estimated_fov, spread = fov_statistics(consistent)
|
||||||
estimated_fov = (
|
|
||||||
fovs[len(fovs) // 2] if len(fovs) % 2
|
|
||||||
else (fovs[len(fovs) // 2 - 1] + fovs[len(fovs) // 2]) / 2
|
|
||||||
)
|
|
||||||
spread = (max(fovs) - min(fovs)) / estimated_fov
|
|
||||||
if len(consistent) == 2:
|
if len(consistent) == 2:
|
||||||
strong_pair = all(
|
strong_pair = all(
|
||||||
int(sample[3].get("inliers") or 0) >= 100
|
(
|
||||||
and float(sample[3].get("inlier_ratio") or 0) >= 0.65
|
int(sample[3].get("inliers") or 0) >= 100
|
||||||
|
and float(sample[3].get("inlier_ratio") or 0) >= 0.65
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
sample[3].get("match_method") == "phase_correlation"
|
||||||
|
and float(sample[3].get("phase_response") or 0) >= 0.35
|
||||||
|
)
|
||||||
for sample in consistent
|
for sample in consistent
|
||||||
)
|
)
|
||||||
allowed_spread = 0.08
|
allowed_spread = 0.08
|
||||||
@@ -162,6 +254,34 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
|
|
||||||
target_overlap = TARGET_OVERLAPS[axis]
|
target_overlap = TARGET_OVERLAPS[axis]
|
||||||
step = max(1, round(estimated_fov * (1.0 - target_overlap)))
|
step = max(1, round(estimated_fov * (1.0 - target_overlap)))
|
||||||
|
visual_direction = None
|
||||||
|
if axis == "pan":
|
||||||
|
# Interactive pan probes always use a positive PTZ delta. The signed
|
||||||
|
# image displacement reveals the real pan direction of this exact
|
||||||
|
# camera/firmware; mount style is not a reliable substitute.
|
||||||
|
signed_shifts = [
|
||||||
|
float(sample[3].get("center_dx", 0.0))
|
||||||
|
for sample in consistent
|
||||||
|
if abs(float(sample[3].get("center_dx", 0.0))) >= 0.02
|
||||||
|
]
|
||||||
|
if len(signed_shifts) < 2:
|
||||||
|
core.move(origin)
|
||||||
|
core.fail(
|
||||||
|
"pan方向缺少至少2个可靠的带符号位移样本;"
|
||||||
|
"请更换纹理分布更均匀的位置重新执行第四步"
|
||||||
|
)
|
||||||
|
positive = sum(shift > 0 for shift in signed_shifts)
|
||||||
|
negative = sum(shift < 0 for shift in signed_shifts)
|
||||||
|
agreement = max(positive, negative) / len(signed_shifts)
|
||||||
|
if agreement < 0.75:
|
||||||
|
core.move(origin)
|
||||||
|
core.fail(
|
||||||
|
f"pan方向样本符号不一致(一致率{agreement*100:.1f}%),"
|
||||||
|
"疑似重复图案或近距离视差;本次不生成扫描弧段"
|
||||||
|
)
|
||||||
|
# center_dx < 0 means positive numeric pan advances the camera view
|
||||||
|
# toward visual right, so left-to-right is numerically increasing.
|
||||||
|
visual_direction = "increasing" if negative > positive else "decreasing"
|
||||||
measured_step, measured_overlap, _, detail = max(
|
measured_step, measured_overlap, _, detail = max(
|
||||||
consistent, key=lambda sample: int(sample[3].get("inliers") or 0)
|
consistent, key=lambda sample: int(sample[3].get("inliers") or 0)
|
||||||
)
|
)
|
||||||
@@ -170,6 +290,11 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
f" {axis}视场一致性通过:{len(consistent)}个样本,"
|
f" {axis}视场一致性通过:{len(consistent)}个样本,"
|
||||||
f"估算视场={estimated_fov:.0f},离散度={spread*100:.1f}%"
|
f"估算视场={estimated_fov:.0f},离散度={spread*100:.1f}%"
|
||||||
)
|
)
|
||||||
|
if visual_direction is not None:
|
||||||
|
say(
|
||||||
|
f" 已自动识别该摄像头:画面左→右时pan数值"
|
||||||
|
f"{'递增' if visual_direction == 'increasing' else '递减'}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"value": step,
|
"value": step,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
@@ -178,9 +303,12 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
"overlap_method": "axis_shift_v1_retargeted",
|
"overlap_method": "axis_shift_v1_retargeted",
|
||||||
"zoom": origin["zoom"],
|
"zoom": origin["zoom"],
|
||||||
"measured_at": origin,
|
"measured_at": origin,
|
||||||
|
**({"visual_direction": visual_direction} if visual_direction else {}),
|
||||||
"good_matches": detail.get("good_matches"),
|
"good_matches": detail.get("good_matches"),
|
||||||
"inliers": detail.get("inliers"),
|
"inliers": detail.get("inliers"),
|
||||||
"inlier_ratio": detail.get("inlier_ratio"),
|
"inlier_ratio": detail.get("inlier_ratio"),
|
||||||
|
"match_method": detail.get("match_method", "orb_homography"),
|
||||||
|
"phase_response": detail.get("phase_response"),
|
||||||
"derived_from": {
|
"derived_from": {
|
||||||
"value": measured_step,
|
"value": measured_step,
|
||||||
"overlap": measured_overlap,
|
"overlap": measured_overlap,
|
||||||
@@ -192,6 +320,8 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
|||||||
"estimated_fov": round(sample[2], 4),
|
"estimated_fov": round(sample[2], 4),
|
||||||
"inliers": sample[3].get("inliers"),
|
"inliers": sample[3].get("inliers"),
|
||||||
"inlier_ratio": sample[3].get("inlier_ratio"),
|
"inlier_ratio": sample[3].get("inlier_ratio"),
|
||||||
|
"match_method": sample[3].get("match_method", "orb_homography"),
|
||||||
|
"phase_response": sample[3].get("phase_response"),
|
||||||
}
|
}
|
||||||
for sample in consistent
|
for sample in consistent
|
||||||
],
|
],
|
||||||
@@ -207,9 +337,16 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
|
|||||||
core.fail("三个位置的zoom不一致;请固定焦段后重新开始")
|
core.fail("三个位置的zoom不一致;请固定焦段后重新开始")
|
||||||
top_left = points["top-left"]
|
top_left = points["top-left"]
|
||||||
bottom_right = points["bottom-right"]
|
bottom_right = points["bottom-right"]
|
||||||
pan_start, _, pan_span, _ = core.scan_pan_range(
|
# This check is only advisory and runs before signed pan direction has
|
||||||
int(top_left["pan"]), int(bottom_right["pan"]), mount
|
# been measured. Use the shorter arc solely to decide whether the chosen
|
||||||
)
|
# calibration scene is plausibly between the two recorded corners.
|
||||||
|
top_left_pan = int(top_left["pan"]) % 36000
|
||||||
|
bottom_right_pan = int(bottom_right["pan"]) % 36000
|
||||||
|
increasing_span = (bottom_right_pan - top_left_pan) % 36000
|
||||||
|
if increasing_span <= 18000:
|
||||||
|
pan_start, pan_span = top_left_pan, increasing_span
|
||||||
|
else:
|
||||||
|
pan_start, pan_span = bottom_right_pan, 36000 - increasing_span
|
||||||
pan_inside = (origin["pan"] - pan_start) % 36000 <= pan_span
|
pan_inside = (origin["pan"] - pan_start) % 36000 <= pan_span
|
||||||
tilt_low = min(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
tilt_low = min(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
||||||
tilt_high = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
tilt_high = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
||||||
@@ -301,15 +438,19 @@ def run_test_scan() -> dict:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
line = process.stdout.readline() if process.stdout else ""
|
line = ""
|
||||||
match = saved_pattern.search(line)
|
if process.stdout:
|
||||||
if match:
|
readable, _, _ = select.select([process.stdout], [], [], 1.0)
|
||||||
progress = (int(match.group(1)), int(match.group(2)))
|
if readable:
|
||||||
if progress != last_progress:
|
line = process.stdout.readline()
|
||||||
say(f"已保存第 {progress[0]}/{progress[1]} 张图")
|
match = saved_pattern.search(line)
|
||||||
last_progress = progress
|
if match:
|
||||||
|
progress = (int(match.group(1)), int(match.group(2)))
|
||||||
|
if progress != last_progress:
|
||||||
|
say(f"已保存第 {progress[0]}/{progress[1]} 张图")
|
||||||
|
last_progress = progress
|
||||||
status = core.api("GET", "/api/v1/scan/status", timeout=5).get("scan", {})
|
status = core.api("GET", "/api/v1/scan/status", timeout=5).get("scan", {})
|
||||||
if status.get("state") in {"completed", "failed"}:
|
if status.get("state") in {"completed", "failed", "stopped"}:
|
||||||
if status.get("state") == "completed":
|
if status.get("state") == "completed":
|
||||||
say(
|
say(
|
||||||
f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。"
|
f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。"
|
||||||
@@ -317,10 +458,20 @@ def run_test_scan() -> dict:
|
|||||||
color=GREEN,
|
color=GREEN,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
say(f"\n扫描失败:{status.get('error') or '未知错误'}", color=RED)
|
message = (
|
||||||
|
"扫描已停止。"
|
||||||
|
if status.get("state") == "stopped"
|
||||||
|
else f"扫描失败:{status.get('error') or '未知错误'}"
|
||||||
|
)
|
||||||
|
say(f"\n{message}", color=YELLOW if status.get("state") == "stopped" else RED)
|
||||||
return status
|
return status
|
||||||
if not line:
|
except KeyboardInterrupt:
|
||||||
time.sleep(1)
|
say("\n收到中断,正在通知smart-cam停止本次扫描……", color=YELLOW)
|
||||||
|
try:
|
||||||
|
core.api("POST", "/api/v1/scan/stop", timeout=10)
|
||||||
|
except (SystemExit, subprocess.SubprocessError):
|
||||||
|
say("停止接口调用失败,请检查scan状态。", color=RED)
|
||||||
|
raise Cancelled
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
try:
|
try:
|
||||||
@@ -329,7 +480,7 @@ def run_test_scan() -> dict:
|
|||||||
process.kill()
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
def run_panorama(scan_status: dict, mount: str) -> Path:
|
def run_panorama(scan_status: dict, mount: str, pan_direction: str) -> Path:
|
||||||
scan_dir = str(scan_status.get("output_dir") or "")
|
scan_dir = str(scan_status.get("output_dir") or "")
|
||||||
scan_id = str(scan_status.get("scan_id") or "")
|
scan_id = str(scan_status.get("scan_id") or "")
|
||||||
# stitch_scan.py runs on the host. A scan_id resolves beneath
|
# stitch_scan.py runs on the host. A scan_id resolves beneath
|
||||||
@@ -340,7 +491,11 @@ def run_panorama(scan_status: dict, mount: str) -> Path:
|
|||||||
raise RuntimeError("扫描状态中没有output_dir或scan_id,无法确定拼图来源")
|
raise RuntimeError("扫描状态中没有output_dir或scan_id,无法确定拼图来源")
|
||||||
stitch = ROOT / "scan-panorama" / "stitch.sh"
|
stitch = ROOT / "scan-panorama" / "stitch.sh"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(stitch), source, "--mount", mount],
|
[
|
||||||
|
str(stitch), source,
|
||||||
|
"--mount", mount,
|
||||||
|
"--pan-direction", pan_direction,
|
||||||
|
],
|
||||||
text=True, capture_output=True,
|
text=True, capture_output=True,
|
||||||
)
|
)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
@@ -405,7 +560,7 @@ def main() -> None:
|
|||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
say(f"\n本次视场标定没有通过:{exc}", color=RED)
|
say(f"\n本次视场标定没有通过:{exc}", color=RED)
|
||||||
choose(
|
choose(
|
||||||
"请换一个纹理更丰富的位置重新尝试第四步,两个角点无需重录。\n"
|
"请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n"
|
||||||
"重新尝试回复 1;结束对话回复 3。",
|
"重新尝试回复 1;结束对话回复 3。",
|
||||||
{"1"},
|
{"1"},
|
||||||
)
|
)
|
||||||
@@ -439,7 +594,15 @@ def main() -> None:
|
|||||||
{"1"},
|
{"1"},
|
||||||
)
|
)
|
||||||
if stitch == "1":
|
if stitch == "1":
|
||||||
panorama = run_panorama(scan_status, mount)
|
pan_direction = str(
|
||||||
|
core.load_state()
|
||||||
|
.get("steps", {})
|
||||||
|
.get("pan", {})
|
||||||
|
.get("visual_direction", "")
|
||||||
|
)
|
||||||
|
if pan_direction not in {"increasing", "decreasing"}:
|
||||||
|
raise RuntimeError("缺少本次实测pan方向,无法可靠排列拼图左右顺序")
|
||||||
|
panorama = run_panorama(scan_status, mount, pan_direction)
|
||||||
say(f"\n拼图完成。\n大图存储位置:{panorama}", color=GREEN)
|
say(f"\n拼图完成。\n大图存储位置:{panorama}", color=GREEN)
|
||||||
success = True
|
success = True
|
||||||
say("\n配置向导已完成,对话结束。", color=GREEN)
|
say("\n配置向导已完成,对话结束。", color=GREEN)
|
||||||
|
|||||||
@@ -50,21 +50,23 @@ find /userdata/data/scan -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
|
|||||||
指定扫描目录名进行拼图:
|
指定扫描目录名进行拼图:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./stitch.sh 2026-07-22_18-25-09 --mount desktop
|
./stitch.sh 2026-07-22_18-25-09 --mount desktop --pan-direction increasing
|
||||||
```
|
```
|
||||||
|
|
||||||
必须用`--mount`声明球机安装方式:
|
必须用`--mount`声明球机安装方式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 办公室测试:球机正放桌面,摄像头后台将画面旋转180度
|
# 办公室测试:球机正放桌面,摄像头后台将画面旋转180度
|
||||||
./stitch.sh 2026-07-22_18-25-09 --mount desktop
|
./stitch.sh 2026-07-22_18-25-09 --mount desktop --pan-direction increasing
|
||||||
|
|
||||||
# 野外部署:球机物理倒挂
|
# 野外部署:球机物理倒挂
|
||||||
./stitch.sh 2026-07-22_18-25-09 --mount hanging
|
./stitch.sh 2026-07-22_18-25-09 --mount hanging --pan-direction increasing
|
||||||
```
|
```
|
||||||
|
|
||||||
- `desktop`同时反转网格行顺序和列顺序;
|
- `desktop`反转上下行顺序,`hanging`保持上下行顺序;
|
||||||
- `hanging`按`meta.json`原始row/col顺序排列;
|
- `--pan-direction increasing`表示画面左到右时pan递增,列保持正序;
|
||||||
|
- `--pan-direction decreasing`表示画面左到右时pan递减,列反序;
|
||||||
|
- pan方向由交互式标定第④步自动识别,不能根据desktop/hanging猜测;
|
||||||
- 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。
|
- 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。
|
||||||
|
|
||||||
目录名会自动解析为:
|
目录名会自动解析为:
|
||||||
|
|||||||
@@ -105,11 +105,23 @@ def identify_dimensions(images: list[Path]) -> tuple[int, int]:
|
|||||||
return dimensions.pop()
|
return dimensions.pop()
|
||||||
|
|
||||||
|
|
||||||
def build_mosaic(scan_dir: Path, tile_width: int, quality: int, mount: str) -> Path:
|
def build_mosaic(
|
||||||
|
scan_dir: Path,
|
||||||
|
tile_width: int,
|
||||||
|
quality: int,
|
||||||
|
mount: str,
|
||||||
|
pan_direction: str,
|
||||||
|
) -> Path:
|
||||||
# Office testing: the dome stands on a desk and the camera firmware rotates
|
# Office testing: the dome stands on a desk and the camera firmware rotates
|
||||||
# the encoded picture by 180 degrees. Field deployment: the dome is hung.
|
# the encoded picture by 180 degrees, which reverses the vertical grid.
|
||||||
|
# Horizontal ordering is camera/firmware-specific and is measured during
|
||||||
|
# calibration; it must not be inferred from the physical mount.
|
||||||
reverse_rows = mount == "desktop"
|
reverse_rows = mount == "desktop"
|
||||||
reverse_cols = mount == "desktop"
|
if pan_direction == "auto":
|
||||||
|
# Backward-compatible behavior for scans made before calibration began
|
||||||
|
# recording the actual signed pan direction.
|
||||||
|
pan_direction = "decreasing" if mount == "desktop" else "increasing"
|
||||||
|
reverse_cols = pan_direction == "decreasing"
|
||||||
scan_id, rows, cols, images = load_grid(scan_dir, reverse_rows, reverse_cols)
|
scan_id, rows, cols, images = load_grid(scan_dir, reverse_rows, reverse_cols)
|
||||||
source_width, source_height = identify_dimensions(images)
|
source_width, source_height = identify_dimensions(images)
|
||||||
if tile_width <= 0:
|
if tile_width <= 0:
|
||||||
@@ -150,7 +162,10 @@ def build_mosaic(scan_dir: Path, tile_width: int, quality: int, mount: str) -> P
|
|||||||
|
|
||||||
print(f"扫描目录:{scan_dir}")
|
print(f"扫描目录:{scan_dir}")
|
||||||
print(f"网格:{cols}列 × {rows}行,共{len(images)}张")
|
print(f"网格:{cols}列 × {rows}行,共{len(images)}张")
|
||||||
print(f"安装方式:{mount}(行={'反向' if reverse_rows else '正向'},列={'反向' if reverse_cols else '正向'})")
|
print(
|
||||||
|
f"安装方式:{mount}(行={'反向' if reverse_rows else '正向'})"
|
||||||
|
f",实测pan方向:{pan_direction}(列={'反向' if reverse_cols else '正向'})"
|
||||||
|
)
|
||||||
print(f"小图:{source_width}×{source_height}")
|
print(f"小图:{source_width}×{source_height}")
|
||||||
if tile_width != requested_tile_width:
|
if tile_width != requested_tile_width:
|
||||||
print(f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到{tile_width},避免超过ImageMagick限制")
|
print(f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到{tile_width},避免超过ImageMagick限制")
|
||||||
@@ -173,6 +188,15 @@ def main() -> None:
|
|||||||
required=True,
|
required=True,
|
||||||
help="desktop=办公室桌面正放且后台旋转180度;hanging=野外倒挂",
|
help="desktop=办公室桌面正放且后台旋转180度;hanging=野外倒挂",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pan-direction",
|
||||||
|
choices=["auto", "increasing", "decreasing"],
|
||||||
|
default="auto",
|
||||||
|
help=(
|
||||||
|
"画面左到右时pan数值方向;交互式向导会自动传入。"
|
||||||
|
"旧扫描未记录方向时默认auto沿用历史mount规则"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--tile-width",
|
"--tile-width",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -188,7 +212,13 @@ def main() -> None:
|
|||||||
help="输出JPEG质量,默认92",
|
help="输出JPEG质量,默认92",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
build_mosaic(resolve_scan_dir(args.scan_dir), args.tile_width, args.quality, args.mount)
|
build_mosaic(
|
||||||
|
resolve_scan_dir(args.scan_dir),
|
||||||
|
args.tile_width,
|
||||||
|
args.quality,
|
||||||
|
args.mount,
|
||||||
|
args.pan_direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+61
-11
@@ -20,6 +20,7 @@ HELPER_CONTAINER_SOURCE = "/tmp/scan-calibrator-measure.cpp"
|
|||||||
HELPER_CONTAINER_BIN = "/tmp/scan-calibrator-measure"
|
HELPER_CONTAINER_BIN = "/tmp/scan-calibrator-measure"
|
||||||
DEFAULT_TARGET_OVERLAP = 0.04
|
DEFAULT_TARGET_OVERLAP = 0.04
|
||||||
MAX_GENERATED_OVERLAP = 0.05
|
MAX_GENERATED_OVERLAP = 0.05
|
||||||
|
PHASE_MIN_RESPONSE = 0.20
|
||||||
|
|
||||||
|
|
||||||
def fail(message: str) -> "NoReturn":
|
def fail(message: str) -> "NoReturn":
|
||||||
@@ -120,6 +121,40 @@ def measure_delta(axis: str, delta: int, direction: int, wait_seconds: float,
|
|||||||
time.sleep(wait_seconds)
|
time.sleep(wait_seconds)
|
||||||
candidate = snapshot()
|
candidate = snapshot()
|
||||||
result = measure_images(baseline, candidate)
|
result = measure_images(baseline, candidate)
|
||||||
|
if axis == "pan":
|
||||||
|
actual_motion = abs(
|
||||||
|
((actual["pan"] - origin["pan"] + 18000) % 36000) - 18000
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actual_motion = abs(actual["tilt"] - origin["tilt"])
|
||||||
|
result["actual_axis_motion"] = actual_motion
|
||||||
|
result["requested_axis_motion"] = delta
|
||||||
|
|
||||||
|
phase_primary_key = "phase_dx" if axis == "pan" else "phase_dy"
|
||||||
|
phase_cross_key = "phase_dy" if axis == "pan" else "phase_dx"
|
||||||
|
phase_primary = abs(float(result.get(phase_primary_key, 0.0)))
|
||||||
|
phase_cross = abs(float(result.get(phase_cross_key, 0.0)))
|
||||||
|
phase_response = float(result.get("phase_response", 0.0))
|
||||||
|
minimum_plausible_shift = delta / 18000.0
|
||||||
|
if (
|
||||||
|
not result.get("valid")
|
||||||
|
and phase_response >= PHASE_MIN_RESPONSE
|
||||||
|
and minimum_plausible_shift <= phase_primary <= 0.50
|
||||||
|
and phase_cross <= 0.05
|
||||||
|
):
|
||||||
|
result.update({
|
||||||
|
"valid": True,
|
||||||
|
"reason": "phase_correlation_fallback",
|
||||||
|
"match_method": "phase_correlation",
|
||||||
|
"center_dx": float(result.get("phase_dx", 0.0)),
|
||||||
|
"center_dy": float(result.get("phase_dy", 0.0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Absolute PTZ commands can be clamped by a camera's mechanical/firmware
|
||||||
|
# limit. In that case identical images are not a feature-texture failure.
|
||||||
|
if actual_motion < max(2, round(delta * 0.5)):
|
||||||
|
result["valid"] = False
|
||||||
|
result["reason"] = "ptz_motion_shortfall"
|
||||||
if result.get("valid"):
|
if result.get("valid"):
|
||||||
primary_key = "center_dx" if axis == "pan" else "center_dy"
|
primary_key = "center_dx" if axis == "pan" else "center_dy"
|
||||||
cross_key = "center_dy" if axis == "pan" else "center_dx"
|
cross_key = "center_dy" if axis == "pan" else "center_dx"
|
||||||
@@ -134,7 +169,6 @@ def measure_delta(axis: str, delta: int, direction: int, wait_seconds: float,
|
|||||||
# wall patterns. Use displacement along the commanded axis instead.
|
# wall patterns. Use displacement along the commanded axis instead.
|
||||||
result["overlap"] = max(0.0, min(1.0, 1.0 - primary_shift))
|
result["overlap"] = max(0.0, min(1.0, 1.0 - primary_shift))
|
||||||
|
|
||||||
minimum_plausible_shift = delta / 18000.0
|
|
||||||
result["minimum_plausible_shift"] = minimum_plausible_shift
|
result["minimum_plausible_shift"] = minimum_plausible_shift
|
||||||
if primary_shift < minimum_plausible_shift:
|
if primary_shift < minimum_plausible_shift:
|
||||||
result["valid"] = False
|
result["valid"] = False
|
||||||
@@ -303,23 +337,34 @@ def command_calibrate(args: argparse.Namespace) -> None:
|
|||||||
"good_matches": detail.get("good_matches"), "inliers": detail.get("inliers"),
|
"good_matches": detail.get("good_matches"), "inliers": detail.get("inliers"),
|
||||||
"inlier_ratio": detail.get("inlier_ratio"),
|
"inlier_ratio": detail.get("inlier_ratio"),
|
||||||
}
|
}
|
||||||
|
if args.axis == "pan":
|
||||||
|
state["steps"][args.axis]["visual_direction"] = (
|
||||||
|
"increasing" if args.direction == 1 else "decreasing"
|
||||||
|
)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
print(f"推荐 {args.axis}_step={step}({step/100:.2f}°),实测重叠={overlap*100:.1f}%")
|
print(f"推荐 {args.axis}_step={step}({step/100:.2f}°),实测重叠={overlap*100:.1f}%")
|
||||||
print("推荐值已写入 calibration.json")
|
print("推荐值已写入 calibration.json")
|
||||||
|
|
||||||
|
|
||||||
def scan_pan_range(top_left: int, bottom_right: int, mount: str) -> tuple[int, int, int, str]:
|
def scan_pan_range(
|
||||||
|
top_left: int,
|
||||||
|
bottom_right: int,
|
||||||
|
mount: str,
|
||||||
|
visual_direction: str | None = None,
|
||||||
|
) -> tuple[int, int, int, str]:
|
||||||
"""Encode the user-selected visual left-to-right arc for the current scanner.
|
"""Encode the user-selected visual left-to-right arc for the current scanner.
|
||||||
|
|
||||||
With the tested desktop setup, firmware rotates the image by 180 degrees, so
|
visual_direction should be measured from signed image displacement on this
|
||||||
visual left-to-right is decreasing PTZ pan. The current scanner only executes
|
exact camera model. mount remains only as a backward-compatible fallback for
|
||||||
increasing pan, therefore reversing both endpoints traverses the exact same
|
older manual calibration state.
|
||||||
selected arc in the opposite order. A normally hanging camera uses the native
|
|
||||||
increasing direction.
|
|
||||||
"""
|
"""
|
||||||
top_left %= 36000
|
top_left %= 36000
|
||||||
bottom_right %= 36000
|
bottom_right %= 36000
|
||||||
if mount == "desktop":
|
if visual_direction is None:
|
||||||
|
visual_direction = "decreasing" if mount == "desktop" else "increasing"
|
||||||
|
if visual_direction not in {"increasing", "decreasing"}:
|
||||||
|
fail(f"未知pan视觉方向:{visual_direction}")
|
||||||
|
if visual_direction == "decreasing":
|
||||||
span = (top_left - bottom_right) % 36000
|
span = (top_left - bottom_right) % 36000
|
||||||
return bottom_right, top_left, span, "decreasing"
|
return bottom_right, top_left, span, "decreasing"
|
||||||
span = (bottom_right - top_left) % 36000
|
span = (bottom_right - top_left) % 36000
|
||||||
@@ -370,16 +415,21 @@ def command_generate(args: argparse.Namespace) -> None:
|
|||||||
f"请使用--target-overlap {DEFAULT_TARGET_OVERLAP:.2f}重新标定"
|
f"请使用--target-overlap {DEFAULT_TARGET_OVERLAP:.2f}重新标定"
|
||||||
)
|
)
|
||||||
inliers = int(info.get("inliers") or 0)
|
inliers = int(info.get("inliers") or 0)
|
||||||
if inliers < 50:
|
phase_response = float(info.get("phase_response") or 0.0)
|
||||||
|
if inliers < 50 and phase_response < PHASE_MIN_RESPONSE:
|
||||||
fail(
|
fail(
|
||||||
f"{axis}_step标定只有{inliers}个几何内点,低于可靠阈值50;"
|
f"{axis}_step标定质量不足(几何内点={inliers},"
|
||||||
|
f"相位响应={phase_response:.2f});"
|
||||||
f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}"
|
f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}"
|
||||||
)
|
)
|
||||||
if abs(int(top_left["zoom"]) - int(bottom_right["zoom"])) > 10:
|
if abs(int(top_left["zoom"]) - int(bottom_right["zoom"])) > 10:
|
||||||
fail("左上角和右下角zoom不一致,请固定zoom后重新记录两个角点")
|
fail("左上角和右下角zoom不一致,请固定zoom后重新记录两个角点")
|
||||||
zoom = round((int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2)
|
zoom = round((int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2)
|
||||||
pan_start, pan_end, pan_span, visual_pan_direction = scan_pan_range(
|
pan_start, pan_end, pan_span, visual_pan_direction = scan_pan_range(
|
||||||
int(top_left["pan"]), int(bottom_right["pan"]), args.mount
|
int(top_left["pan"]),
|
||||||
|
int(bottom_right["pan"]),
|
||||||
|
args.mount,
|
||||||
|
pan_info.get("visual_direction"),
|
||||||
)
|
)
|
||||||
tilt_start = min(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
tilt_start = min(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
||||||
tilt_end = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
tilt_end = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
|
||||||
|
|||||||
+21
-4
@@ -16,12 +16,23 @@ int main(int argc, char** argv) {
|
|||||||
cv::resize(a, a, {}, scale, scale, cv::INTER_AREA);
|
cv::resize(a, a, {}, scale, scale, cv::INTER_AREA);
|
||||||
cv::resize(b, b, {}, scale, scale, cv::INTER_AREA);
|
cv::resize(b, b, {}, scale, scale, cv::INTER_AREA);
|
||||||
}
|
}
|
||||||
|
cv::Mat af, bf, window;
|
||||||
|
a.convertTo(af, CV_32F);
|
||||||
|
b.convertTo(bf, CV_32F);
|
||||||
|
cv::createHanningWindow(window, a.size(), CV_32F);
|
||||||
|
double phase_response = 0.0;
|
||||||
|
const cv::Point2d phase = cv::phaseCorrelate(af, bf, window, &phase_response);
|
||||||
|
const double phase_dx = phase.x / a.cols;
|
||||||
|
const double phase_dy = phase.y / a.rows;
|
||||||
|
|
||||||
auto orb = cv::ORB::create(2500, 1.2f, 8, 31, 0, 2, cv::ORB::HARRIS_SCORE, 31, 8);
|
auto orb = cv::ORB::create(2500, 1.2f, 8, 31, 0, 2, cv::ORB::HARRIS_SCORE, 31, 8);
|
||||||
std::vector<cv::KeyPoint> ka, kb; cv::Mat da, db;
|
std::vector<cv::KeyPoint> ka, kb; cv::Mat da, db;
|
||||||
orb->detectAndCompute(a, {}, ka, da);
|
orb->detectAndCompute(a, {}, ka, da);
|
||||||
orb->detectAndCompute(b, {}, kb, db);
|
orb->detectAndCompute(b, {}, kb, db);
|
||||||
if (da.empty() || db.empty()) {
|
if (da.empty() || db.empty()) {
|
||||||
std::cout << "{\"valid\":false,\"reason\":\"not_enough_features\",\"overlap\":0}\n";
|
std::cout << "{\"valid\":false,\"reason\":\"not_enough_features\",\"overlap\":0"
|
||||||
|
<< ",\"phase_dx\":" << phase_dx << ",\"phase_dy\":" << phase_dy
|
||||||
|
<< ",\"phase_response\":" << phase_response << "}\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
std::vector<std::vector<cv::DMatch>> knn;
|
std::vector<std::vector<cv::DMatch>> knn;
|
||||||
@@ -31,7 +42,9 @@ int main(int argc, char** argv) {
|
|||||||
if (pair.size() == 2 && pair[0].distance < 0.75f * pair[1].distance) good.push_back(pair[0]);
|
if (pair.size() == 2 && pair[0].distance < 0.75f * pair[1].distance) good.push_back(pair[0]);
|
||||||
if (good.size() < 8) {
|
if (good.size() < 8) {
|
||||||
std::cout << "{\"valid\":false,\"reason\":\"not_enough_matches\",\"good_matches\":"
|
std::cout << "{\"valid\":false,\"reason\":\"not_enough_matches\",\"good_matches\":"
|
||||||
<< good.size() << ",\"overlap\":0}\n";
|
<< good.size() << ",\"overlap\":0"
|
||||||
|
<< ",\"phase_dx\":" << phase_dx << ",\"phase_dy\":" << phase_dy
|
||||||
|
<< ",\"phase_response\":" << phase_response << "}\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
std::vector<cv::Point2f> pa, pb;
|
std::vector<cv::Point2f> pa, pb;
|
||||||
@@ -43,7 +56,9 @@ int main(int argc, char** argv) {
|
|||||||
if (h.empty() || inliers < 50 || inlier_ratio < 0.45) {
|
if (h.empty() || inliers < 50 || inlier_ratio < 0.45) {
|
||||||
std::cout << "{\"valid\":false,\"reason\":\"homography_failed\",\"good_matches\":"
|
std::cout << "{\"valid\":false,\"reason\":\"homography_failed\",\"good_matches\":"
|
||||||
<< good.size() << ",\"inliers\":" << inliers
|
<< good.size() << ",\"inliers\":" << inliers
|
||||||
<< ",\"inlier_ratio\":" << inlier_ratio << ",\"overlap\":0}\n";
|
<< ",\"inlier_ratio\":" << inlier_ratio << ",\"overlap\":0"
|
||||||
|
<< ",\"phase_dx\":" << phase_dx << ",\"phase_dy\":" << phase_dy
|
||||||
|
<< ",\"phase_response\":" << phase_response << "}\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +78,8 @@ int main(int argc, char** argv) {
|
|||||||
<< ",\"good_matches\":" << good.size()
|
<< ",\"good_matches\":" << good.size()
|
||||||
<< ",\"inliers\":" << inliers
|
<< ",\"inliers\":" << inliers
|
||||||
<< ",\"inlier_ratio\":" << inlier_ratio
|
<< ",\"inlier_ratio\":" << inlier_ratio
|
||||||
<< ",\"center_dx\":" << dx << ",\"center_dy\":" << dy << "}\n";
|
<< ",\"center_dx\":" << dx << ",\"center_dy\":" << dy
|
||||||
|
<< ",\"phase_dx\":" << phase_dx << ",\"phase_dy\":" << phase_dy
|
||||||
|
<< ",\"phase_response\":" << phase_response << "}\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user