Fits small cameras
This commit is contained in:
1 parent
a773166c91
commit
98ed2576c6
7 files changed
+526
-96
No files matched your search
+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)
|
||||
|
||||
Reference in new issue
Block a user