Fits small cameras

This commit is contained in:
Ankle committed 2026-07-29 16:25:16 +08:00
1 parent a773166c91
commit 98ed2576c6
7 files changed
+526 -96

No files matched your search

+40 -10
View File
@@ -9,6 +9,7 @@
scan-calibrator/
├── README.md # 交互式向导主文档
├── 手动配置README.md # 完整手动流程和参数原理
├── install.sh # 首次部署时安装依赖并做完整环境自检
├── interactive.sh # 交互式向导入口
├── interactive_calibrator.py
├── calibrate.sh # 手动配置入口
@@ -24,11 +25,22 @@ scan-calibrator/
## 启动
首次把本目录复制到一台RK3588后,先执行一次安装与自检:
```bash
cd /home/zhangw/scan-calibrator
./install.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`:确认、执行或选择野外部署;
@@ -133,7 +145,8 @@ cd /home/zhangw/scan-calibrator
- 回复`1`:使用`hanging`,表示球机在野外正常倒挂;
- 回复`2`:使用`desktop`,表示办公室桌面正放且摄像头后台旋转了180度。
它决定角点的pan方向和生成配置时的扫描顺序,等价于最后执行:
它决定验证拼图时的画面方向。摄像头的pan数值递增/递减方向不再由安装姿态写死,
而是在第④步根据实拍画面位移自动识别。生成配置仍等价于最后执行:
```bash
./calibrate.sh generate --mount hanging
@@ -173,11 +186,15 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
2. 沿pan方向尝试若干候选角度,每次抓图后回到起点;
3. 用ORB特征、RANSAC和单轴画面位移求水平视场;
4. 沿tilt方向重复测量;
5. 每个方向用不同移动量反算视场:两组结果只有在特征质量高且视场差不超过8%时才接受,
否则要求至少三组一致结果;
6. 水平方向按1%目标重叠计算step,垂直方向按4%目标重叠计算step;
7. 要求至少50个几何内点且内点率至少45%,否则拒绝结果;
8. 删除本次自动标定产生的临时截图。
5. 每个方向会跑完预设的全部候选step,再统一筛选相互一致的视场样本;不因前几个样本
离散度低而提前停止;
6. 两组结果只有在特征质量高且视场差不超过8%时才接受,否则要求至少三组一致结果;
7. 水平方向按4%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿;
8. 默认使用ORB特征和RANSAC几何变换;当局部图案重复导致几何内点不足时,自动使用整幅图
相位相关复核纯单轴位移,相位响应不得低于0.20;
8. 要求至少50个几何内点且内点率至少45%,否则拒绝结果;
9. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减;
10. 删除本次自动标定产生的临时截图。
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和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`后,向导将本次扫描的结果目录和第①步
@@ -250,9 +275,13 @@ docker exec smart-cam curl -fsS \
```bash
/home/zhangw/scan-calibrator/scan-panorama/stitch.sh \
<本次扫描目录> --mount <desktop或hanging>
<本次扫描目录> --mount <desktop或hanging> \
--pan-direction <increasing或decreasing>
```
`mount`只决定上下行顺序;左右列顺序使用第④步实拍得到的pan方向。交互式向导会自动传入,
不需要现场人员额外选择。
拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。
拼图结果统一保存在:
@@ -267,8 +296,9 @@ docker exec smart-cam curl -fsS \
- 每一个摄像头点位都要重新记录角点;
- 摄像头型号或zoom变化时必须重新自动计算step;
- 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
或“默认走短弧”代替;
- 标定期间不要同时从Web界面控制PTZ;
- 水平目标重叠为1%,垂直目标重叠为4%,都不超过5%上限;
- 自动标定不会使用单个样本;两组样本必须同时满足高特征质量和视场差不超过8%,
普通质量的结果必须由至少三组样本互相验证;
- 向导按轴设置目标重叠:水平4%、垂直2%;最终以完整试扫的接缝结果为准;
- 自动标定会完整测试全部候选step,再从所有可靠结果中筛选一致样本;
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
Executable
+138
View File
@@ -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
View File
@@ -10,6 +10,7 @@ import io
import json
from pathlib import Path
import re
import select
import subprocess
import sys
import tempfile
@@ -20,7 +21,11 @@ import scan_calibrator as core
ROOT = Path(__file__).resolve().parent
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"
GREEN = "\033[32m"
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
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]] = []
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.
def consistent_samples(reliable: list[tuple[int, float, float, dict]]) -> list:
"""Return the largest group that estimates one mutually consistent FOV."""
if not reliable:
return []
clusters = [
[
sample for sample in reliable
@@ -120,9 +96,124 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
]
for seed in reliable
]
consistent = max(
return max(
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:
core.move(origin)
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 = (
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
estimated_fov, spread = fov_statistics(consistent)
if len(consistent) == 2:
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
)
allowed_spread = 0.08
@@ -162,6 +254,34 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
target_overlap = TARGET_OVERLAPS[axis]
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(
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"估算视场={estimated_fov:.0f},离散度={spread*100:.1f}%"
)
if visual_direction is not None:
say(
f" 已自动识别该摄像头:画面左→右时pan数值"
f"{'递增' if visual_direction == 'increasing' else '递减'}"
)
return {
"value": step,
"direction": direction,
@@ -178,9 +303,12 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
"overlap_method": "axis_shift_v1_retargeted",
"zoom": origin["zoom"],
"measured_at": origin,
**({"visual_direction": visual_direction} if visual_direction else {}),
"good_matches": detail.get("good_matches"),
"inliers": detail.get("inliers"),
"inlier_ratio": detail.get("inlier_ratio"),
"match_method": detail.get("match_method", "orb_homography"),
"phase_response": detail.get("phase_response"),
"derived_from": {
"value": measured_step,
"overlap": measured_overlap,
@@ -192,6 +320,8 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
"estimated_fov": round(sample[2], 4),
"inliers": sample[3].get("inliers"),
"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
],
@@ -207,9 +337,16 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
core.fail("三个位置的zoom不一致;请固定焦段后重新开始")
top_left = points["top-left"]
bottom_right = points["bottom-right"]
pan_start, _, pan_span, _ = core.scan_pan_range(
int(top_left["pan"]), int(bottom_right["pan"]), mount
)
# This check is only advisory and runs before signed pan direction has
# 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
tilt_low = min(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:
while True:
line = process.stdout.readline() if process.stdout else ""
match = saved_pattern.search(line)
if match:
progress = (int(match.group(1)), int(match.group(2)))
if progress != last_progress:
say(f"已保存第 {progress[0]}/{progress[1]} 张图")
last_progress = progress
line = ""
if process.stdout:
readable, _, _ = select.select([process.stdout], [], [], 1.0)
if readable:
line = process.stdout.readline()
match = saved_pattern.search(line)
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", {})
if status.get("state") in {"completed", "failed"}:
if status.get("state") in {"completed", "failed", "stopped"}:
if status.get("state") == "completed":
say(
f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。"
@@ -317,10 +458,20 @@ def run_test_scan() -> dict:
color=GREEN,
)
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
if not line:
time.sleep(1)
except KeyboardInterrupt:
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:
process.terminate()
try:
@@ -329,7 +480,7 @@ def run_test_scan() -> dict:
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_id = str(scan_status.get("scan_id") or "")
# 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,无法确定拼图来源")
stitch = ROOT / "scan-panorama" / "stitch.sh"
result = subprocess.run(
[str(stitch), source, "--mount", mount],
[
str(stitch), source,
"--mount", mount,
"--pan-direction", pan_direction,
],
text=True, capture_output=True,
)
if result.returncode:
@@ -405,7 +560,7 @@ def main() -> None:
except SystemExit as exc:
say(f"\n本次视场标定没有通过:{exc}", color=RED)
choose(
"请换一个纹理更丰富的位置重新尝试第四步,两个角点无需重录。\n"
"请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n"
"重新尝试回复 1;结束对话回复 3。",
{"1"},
)
@@ -439,7 +594,15 @@ def main() -> None:
{"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)
success = True
say("\n配置向导已完成,对话结束。", color=GREEN)
+7 -5
View File
@@ -50,21 +50,23 @@ find /userdata/data/scan -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
指定扫描目录名进行拼图:
```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`声明球机安装方式:
```bash
# 办公室测试:球机正放桌面,摄像头后台将画面旋转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`同时反转网格行顺序和列顺序;
- `hanging`按`meta.json`原始row/col顺序排列;
- `desktop`反转上下行顺序,`hanging`保持上下行顺序;
- `--pan-direction increasing`表示画面左到右时pan递增,列保持正序;
- `--pan-direction decreasing`表示画面左到右时pan递减,列反序;
- pan方向由交互式标定第④步自动识别,不能根据desktop/hanging猜测;
- 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。
目录名会自动解析为:
+35 -5
View File
@@ -105,11 +105,23 @@ def identify_dimensions(images: list[Path]) -> tuple[int, int]:
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
# 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_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)
source_width, source_height = identify_dimensions(images)
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"网格:{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}")
if tile_width != requested_tile_width:
print(f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到{tile_width},避免超过ImageMagick限制")
@@ -173,6 +188,15 @@ def main() -> None:
required=True,
help="desktop=办公室桌面正放且后台旋转180度;hanging=野外倒挂",
)
parser.add_argument(
"--pan-direction",
choices=["auto", "increasing", "decreasing"],
default="auto",
help=(
"画面左到右时pan数值方向;交互式向导会自动传入。"
"旧扫描未记录方向时默认auto沿用历史mount规则"
),
)
parser.add_argument(
"--tile-width",
type=int,
@@ -188,7 +212,13 @@ def main() -> None:
help="输出JPEG质量,默认92",
)
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__":
+61 -11
View File
@@ -20,6 +20,7 @@ HELPER_CONTAINER_SOURCE = "/tmp/scan-calibrator-measure.cpp"
HELPER_CONTAINER_BIN = "/tmp/scan-calibrator-measure"
DEFAULT_TARGET_OVERLAP = 0.04
MAX_GENERATED_OVERLAP = 0.05
PHASE_MIN_RESPONSE = 0.20
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)
candidate = snapshot()
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"):
primary_key = "center_dx" if axis == "pan" else "center_dy"
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.
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
if primary_shift < minimum_plausible_shift:
result["valid"] = False
@@ -303,23 +337,34 @@ def command_calibrate(args: argparse.Namespace) -> None:
"good_matches": detail.get("good_matches"), "inliers": detail.get("inliers"),
"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)
print(f"推荐 {args.axis}_step={step}({step/100:.2f}°),实测重叠={overlap*100:.1f}%")
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.
With the tested desktop setup, firmware rotates the image by 180 degrees, so
visual left-to-right is decreasing PTZ pan. The current scanner only executes
increasing pan, therefore reversing both endpoints traverses the exact same
selected arc in the opposite order. A normally hanging camera uses the native
increasing direction.
visual_direction should be measured from signed image displacement on this
exact camera model. mount remains only as a backward-compatible fallback for
older manual calibration state.
"""
top_left %= 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
return bottom_right, top_left, span, "decreasing"
span = (bottom_right - top_left) % 36000
@@ -370,16 +415,21 @@ def command_generate(args: argparse.Namespace) -> None:
f"请使用--target-overlap {DEFAULT_TARGET_OVERLAP:.2f}重新标定"
)
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(
f"{axis}_step标定只有{inliers}个几何内点,低于可靠阈值50;"
f"{axis}_step标定质量不足(几何内点={inliers},"
f"相位响应={phase_response:.2f});"
f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}"
)
if abs(int(top_left["zoom"]) - int(bottom_right["zoom"])) > 10:
fail("左上角和右下角zoom不一致,请固定zoom后重新记录两个角点")
zoom = round((int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2)
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_end = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
+21 -4
View File
@@ -16,12 +16,23 @@ int main(int argc, char** argv) {
cv::resize(a, a, {}, 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);
std::vector<cv::KeyPoint> ka, kb; cv::Mat da, db;
orb->detectAndCompute(a, {}, ka, da);
orb->detectAndCompute(b, {}, kb, db);
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;
}
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 (good.size() < 8) {
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;
}
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) {
std::cout << "{\"valid\":false,\"reason\":\"homography_failed\",\"good_matches\":"
<< 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;
}
@@ -63,6 +78,8 @@ int main(int argc, char** argv) {
<< ",\"good_matches\":" << good.size()
<< ",\"inliers\":" << inliers
<< ",\"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;
}