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/
|
||||
├── 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
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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猜测;
|
||||
- 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。
|
||||
|
||||
目录名会自动解析为:
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user