diff --git a/README.md b/README.md index 7f42488..5c01be6 100644 --- a/README.md +++ b/README.md @@ -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 + <本次扫描目录> --mount \ + --pan-direction ``` +`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,再从所有可靠结果中筛选一致样本; - 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核; diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e42121c --- /dev/null +++ b/install.sh @@ -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 "$@" diff --git a/interactive_calibrator.py b/interactive_calibrator.py index 2460fd3..6521b3a 100755 --- a/interactive_calibrator.py +++ b/interactive_calibrator.py @@ -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) diff --git a/scan-panorama/README.md b/scan-panorama/README.md index d68a9f6..e8ba2e6 100644 --- a/scan-panorama/README.md +++ b/scan-panorama/README.md @@ -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猜测; - 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。 目录名会自动解析为: diff --git a/scan-panorama/stitch_scan.py b/scan-panorama/stitch_scan.py index 3baf93f..46030d3 100755 --- a/scan-panorama/stitch_scan.py +++ b/scan-panorama/stitch_scan.py @@ -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__": diff --git a/scan_calibrator.py b/scan_calibrator.py index 3ee9c8d..abedb6f 100755 --- a/scan_calibrator.py +++ b/scan_calibrator.py @@ -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"])) diff --git a/src/measure_overlap.cpp b/src/measure_overlap.cpp index 6efd3b0..d0ff400 100644 --- a/src/measure_overlap.cpp +++ b/src/measure_overlap.cpp @@ -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 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> 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 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; }