diff --git a/interactive_calibrator.py b/interactive_calibrator.py index a6f6b85..4f28d3a 100755 --- a/interactive_calibrator.py +++ b/interactive_calibrator.py @@ -63,7 +63,10 @@ def camera_ptz() -> dict[str, int]: def remove_session_snapshots(paths: set[str]) -> None: for path in sorted(paths): - if not path.startswith("/smart-cam/data/scan/calibration/"): + if not path.startswith(( + "/smart-cam/data/scan/manual/", + "/smart-cam/data/scan/calibration/", + )): continue subprocess.run( ["docker", "exec", "smart-cam", "rm", "-f", "--", path], @@ -109,20 +112,31 @@ def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int: return 1 if high - origin["tilt"] >= origin["tilt"] - low else -1 -def consistent_samples(reliable: list[tuple[int, float, float, dict]]) -> list: - """Return the largest group that estimates one mutually consistent FOV.""" +def consistent_samples(reliable: list[tuple[int, float, float, dict]], + maximum_spread: float = 0.15) -> list: + """Return the largest group with a bounded end-to-end FOV spread. + + The previous seed-radius clustering allowed one sample at -12% and + another at +12% into the same cluster. That cluster could therefore have + nearly 24% end-to-end spread and was immediately rejected by the later + 15% check. Use sorted contiguous windows so membership and validation + apply the same definition of consistency. + """ if not reliable: return [] - clusters = [ - [ - sample for sample in reliable - if abs(sample[2] - seed[2]) / max(seed[2], 1.0) <= 0.12 - ] - for seed in reliable - ] - return max( - clusters, key=lambda cluster: (len(cluster), -max(x[2] for x in cluster)) - ) + ordered = sorted(reliable, key=lambda sample: sample[2]) + candidates = [] + for begin in range(len(ordered)): + for end in range(begin, len(ordered)): + window = ordered[begin:end + 1] + _, spread = fov_statistics(window) + if spread <= maximum_spread: + candidates.append(window) + return max(candidates, key=lambda group: ( + len(group), + -fov_statistics(group)[1], + sum(int(sample[3].get("inliers") or 0) for sample in group), + )) def fov_statistics(samples: list[tuple[int, float, float, dict]]) -> tuple[float, float]: @@ -321,8 +335,17 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict, 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 (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.90: + # Hikvision absolute PTZ movement is quantized and can also have + # backlash. At high optical zoom, using the requested delta + # instead of the SDK-reported actual movement creates a large, + # systematic FOV error. Shortfall samples have already been + # rejected above, so the actual movement is authoritative here. + actual_delta = int(result.get("actual_axis_motion") or delta) + reliable.append(( + actual_delta, overlap, + actual_delta / (1.0 - overlap), result, + )) if len(reliable) >= 4: provisional = consistent_samples(reliable) if len(provisional) >= 4: @@ -372,7 +395,10 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict, for sample in consistent ) allowed_spread = 0.08 - if not strong_pair: + # Two independently measured FOVs that agree within 5% are already a + # strong geometric cross-check. Do not reject such a pair merely + # because a natural scene supplied 74 rather than 100 ORB inliers. + if not strong_pair and spread > 0.05: core.move(origin) core.fail( f"{axis}方向目前只有2个一致样本,但特征质量不足以使用双样本规则;"