visual-verifier 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- visual_verifier/__init__.py +80 -0
- visual_verifier/__main__.py +6 -0
- visual_verifier/api.py +122 -0
- visual_verifier/cli.py +883 -0
- visual_verifier/config/__init__.py +15 -0
- visual_verifier/config/defaults.py +45 -0
- visual_verifier/config/targets.py +101 -0
- visual_verifier/config/tracking.py +97 -0
- visual_verifier/demo.py +310 -0
- visual_verifier/detection/__init__.py +29 -0
- visual_verifier/detection/filtering.py +203 -0
- visual_verifier/detection/region_extraction.py +537 -0
- visual_verifier/detection/severity.py +274 -0
- visual_verifier/exceptions.py +108 -0
- visual_verifier/integrations/__init__.py +8 -0
- visual_verifier/media/__init__.py +29 -0
- visual_verifier/media/metadata.py +307 -0
- visual_verifier/media/normalization.py +243 -0
- visual_verifier/media/thumbnails.py +90 -0
- visual_verifier/media/video_reader.py +320 -0
- visual_verifier/media/video_writer.py +127 -0
- visual_verifier/metrics/__init__.py +51 -0
- visual_verifier/metrics/geometry.py +209 -0
- visual_verifier/metrics/pixel.py +345 -0
- visual_verifier/metrics/sharpness.py +249 -0
- visual_verifier/models.py +537 -0
- visual_verifier/pipeline/__init__.py +9 -0
- visual_verifier/pipeline/image_pipeline.py +502 -0
- visual_verifier/pipeline/video_pipeline.py +1232 -0
- visual_verifier/policies/__init__.py +8 -0
- visual_verifier/py.typed +0 -0
- visual_verifier/reporting/__init__.py +17 -0
- visual_verifier/reporting/annotations.py +302 -0
- visual_verifier/reporting/console.py +266 -0
- visual_verifier/reporting/csv_report.py +98 -0
- visual_verifier/reporting/html_report.py +895 -0
- visual_verifier/reporting/json_report.py +133 -0
- visual_verifier/reporting/track_report.py +209 -0
- visual_verifier/targets/__init__.py +38 -0
- visual_verifier/targets/coverage.py +219 -0
- visual_verifier/targets/interpolation.py +172 -0
- visual_verifier/targets/loading.py +410 -0
- visual_verifier/targets/models.py +188 -0
- visual_verifier/targets/validation.py +216 -0
- visual_verifier/tracking/__init__.py +37 -0
- visual_verifier/tracking/analysis.py +337 -0
- visual_verifier/tracking/association.py +145 -0
- visual_verifier/tracking/events.py +203 -0
- visual_verifier/tracking/models.py +387 -0
- visual_verifier/tracking/tracker.py +584 -0
- visual_verifier/type_aliases.py +37 -0
- visual_verifier-0.3.0.dist-info/METADATA +544 -0
- visual_verifier-0.3.0.dist-info/RECORD +57 -0
- visual_verifier-0.3.0.dist-info/WHEEL +5 -0
- visual_verifier-0.3.0.dist-info/entry_points.txt +2 -0
- visual_verifier-0.3.0.dist-info/licenses/LICENSE +201 -0
- visual_verifier-0.3.0.dist-info/top_level.txt +1 -0
visual_verifier/cli.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
"""Provide the command-line interface for Visual Verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from visual_verifier import __version__
|
|
18
|
+
from visual_verifier.api import verify_image, verify_video
|
|
19
|
+
from visual_verifier.config.defaults import DEFAULT_DETECTION_CONFIG
|
|
20
|
+
from visual_verifier.config.targets import (
|
|
21
|
+
DEFAULT_TARGET_CONFIG,
|
|
22
|
+
TargetConfig,
|
|
23
|
+
)
|
|
24
|
+
from visual_verifier.config.tracking import (
|
|
25
|
+
DEFAULT_TRACKING_CONFIG,
|
|
26
|
+
TrackingConfig,
|
|
27
|
+
)
|
|
28
|
+
from visual_verifier.demo import DemonstrationResult, run_demonstration
|
|
29
|
+
from visual_verifier.exceptions import VisualVerifierError
|
|
30
|
+
from visual_verifier.media.metadata import (
|
|
31
|
+
read_image_metadata,
|
|
32
|
+
read_video_metadata,
|
|
33
|
+
)
|
|
34
|
+
from visual_verifier.media.video_writer import find_supported_codec
|
|
35
|
+
from visual_verifier.models import (
|
|
36
|
+
DetectionConfig,
|
|
37
|
+
MediaMetadata,
|
|
38
|
+
VerificationResult,
|
|
39
|
+
)
|
|
40
|
+
from visual_verifier.reporting.console import format_verification_summary
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class _NumericArgumentSpec:
|
|
45
|
+
"""Describe one numeric threshold argument shared by subcommands.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
flag_text: Long option string, such as ``--min-severity``.
|
|
49
|
+
value_type: Converter applied to the raw command-line string.
|
|
50
|
+
default_value: Value taken from the shipped configuration object.
|
|
51
|
+
metavar_text: Placeholder shown in help output.
|
|
52
|
+
help_text: Sentence describing the threshold.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
flag_text: str
|
|
56
|
+
value_type: type[int] | type[float]
|
|
57
|
+
default_value: int | float
|
|
58
|
+
metavar_text: str
|
|
59
|
+
help_text: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
EXIT_SUCCESS_INT = 0
|
|
63
|
+
EXIT_EXECUTION_ERROR_INT = 1
|
|
64
|
+
EXIT_VERIFICATION_FAILED_INT = 2
|
|
65
|
+
DOCTOR_DIVIDER_TEXT = "=" * 40
|
|
66
|
+
VIDEO_SUFFIXES_FROZENSET = frozenset({".mp4", ".avi", ".mov", ".mkv", ".webm"})
|
|
67
|
+
MISSING_CODEC_TEXT = "unavailable"
|
|
68
|
+
HEALTHY_STATUS_TEXT = "Environment status: OK"
|
|
69
|
+
DEMO_INTRODUCTION_TEXT = (
|
|
70
|
+
"Generating a synthetic 15-frame clip in which a licence plate is\n"
|
|
71
|
+
"blurred on every frame except 4, 8, and 12, then verifying it.\n"
|
|
72
|
+
"Nothing is downloaded and nothing leaves this machine.\n"
|
|
73
|
+
)
|
|
74
|
+
DEMO_SUCCESS_TEXT = (
|
|
75
|
+
"The demonstration behaved as documented: the three unprocessed "
|
|
76
|
+
"frames were found."
|
|
77
|
+
)
|
|
78
|
+
DEMO_UNEXPECTED_TEXT = (
|
|
79
|
+
"The demonstration did not reproduce the documented result. Please "
|
|
80
|
+
"report this with the output of `visual-verifier doctor`."
|
|
81
|
+
)
|
|
82
|
+
DEGRADED_STATUS_TEXT = (
|
|
83
|
+
"Environment status: DEGRADED - verification works, but annotated "
|
|
84
|
+
"video evidence cannot be written by this OpenCV build."
|
|
85
|
+
)
|
|
86
|
+
DETECTION_ARGUMENT_SPECS_TUPLE: tuple[_NumericArgumentSpec, ...] = (
|
|
87
|
+
_NumericArgumentSpec(
|
|
88
|
+
"--diff-threshold",
|
|
89
|
+
int,
|
|
90
|
+
DEFAULT_DETECTION_CONFIG.diff_threshold,
|
|
91
|
+
"INT",
|
|
92
|
+
"Absolute per-pixel difference required to mark a pixel as "
|
|
93
|
+
"changed (default: %(default)s).",
|
|
94
|
+
),
|
|
95
|
+
_NumericArgumentSpec(
|
|
96
|
+
"--min-box-area",
|
|
97
|
+
int,
|
|
98
|
+
DEFAULT_DETECTION_CONFIG.min_box_area,
|
|
99
|
+
"INT",
|
|
100
|
+
"Smallest accepted region area (default: %(default)s px).",
|
|
101
|
+
),
|
|
102
|
+
_NumericArgumentSpec(
|
|
103
|
+
"--min-changed-ratio",
|
|
104
|
+
float,
|
|
105
|
+
DEFAULT_DETECTION_CONFIG.min_changed_ratio,
|
|
106
|
+
"FLOAT",
|
|
107
|
+
"Smallest accepted changed-pixel ratio inside a region "
|
|
108
|
+
"(default: %(default)s).",
|
|
109
|
+
),
|
|
110
|
+
_NumericArgumentSpec(
|
|
111
|
+
"--min-mean-diff",
|
|
112
|
+
float,
|
|
113
|
+
DEFAULT_DETECTION_CONFIG.min_mean_diff,
|
|
114
|
+
"FLOAT",
|
|
115
|
+
"Smallest accepted mean intensity difference inside a region "
|
|
116
|
+
"(default: %(default)s).",
|
|
117
|
+
),
|
|
118
|
+
_NumericArgumentSpec(
|
|
119
|
+
"--min-severity",
|
|
120
|
+
float,
|
|
121
|
+
DEFAULT_DETECTION_CONFIG.min_severity_score,
|
|
122
|
+
"FLOAT",
|
|
123
|
+
"Smallest accepted region severity score from 0 to 100 "
|
|
124
|
+
"(default: %(default)s).",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
TRACKING_ARGUMENT_SPECS_TUPLE: tuple[_NumericArgumentSpec, ...] = (
|
|
128
|
+
_NumericArgumentSpec(
|
|
129
|
+
"--tracking-iou",
|
|
130
|
+
float,
|
|
131
|
+
DEFAULT_TRACKING_CONFIG.association_iou_threshold,
|
|
132
|
+
"FLOAT",
|
|
133
|
+
"Minimum intersection-over-union used to associate a region "
|
|
134
|
+
"with an active track (default: %(default)s).",
|
|
135
|
+
),
|
|
136
|
+
_NumericArgumentSpec(
|
|
137
|
+
"--tracking-confirmation-hits",
|
|
138
|
+
int,
|
|
139
|
+
DEFAULT_TRACKING_CONFIG.minimum_confirmation_hits,
|
|
140
|
+
"INT",
|
|
141
|
+
"Observations required before a tentative track is confirmed "
|
|
142
|
+
"(default: %(default)s).",
|
|
143
|
+
),
|
|
144
|
+
_NumericArgumentSpec(
|
|
145
|
+
"--tracking-max-gap",
|
|
146
|
+
int,
|
|
147
|
+
DEFAULT_TRACKING_CONFIG.maximum_gap_frames,
|
|
148
|
+
"INT",
|
|
149
|
+
"Consecutive missing frames tolerated before a track closes "
|
|
150
|
+
"(default: %(default)s).",
|
|
151
|
+
),
|
|
152
|
+
_NumericArgumentSpec(
|
|
153
|
+
"--lineage-overlap",
|
|
154
|
+
float,
|
|
155
|
+
DEFAULT_TRACKING_CONFIG.lineage_overlap_threshold,
|
|
156
|
+
"FLOAT",
|
|
157
|
+
"Minimum overlap used to detect split and merge lineage "
|
|
158
|
+
"events (default: %(default)s).",
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
TARGET_ARGUMENT_SPECS_TUPLE: tuple[_NumericArgumentSpec, ...] = (
|
|
162
|
+
_NumericArgumentSpec(
|
|
163
|
+
"--target-min-coverage",
|
|
164
|
+
float,
|
|
165
|
+
DEFAULT_TARGET_CONFIG.min_covered_ratio,
|
|
166
|
+
"FLOAT",
|
|
167
|
+
"Fraction of a target that accepted processing must cover "
|
|
168
|
+
"(default: %(default)s).",
|
|
169
|
+
),
|
|
170
|
+
_NumericArgumentSpec(
|
|
171
|
+
"--target-max-gap",
|
|
172
|
+
int,
|
|
173
|
+
DEFAULT_TARGET_CONFIG.max_interpolation_gap,
|
|
174
|
+
"INT",
|
|
175
|
+
"Longest run of missing frames interpolated between two "
|
|
176
|
+
"reviewed target boxes (default: %(default)s).",
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
CLI_DESCRIPTION_TEXT = (
|
|
180
|
+
"Reference-based QA for processed image and video media. Visual "
|
|
181
|
+
"Verifier compares an original reference with a processed candidate "
|
|
182
|
+
"and returns deterministic PASS/FAIL evidence."
|
|
183
|
+
)
|
|
184
|
+
CLI_EPILOG_TEXT = (
|
|
185
|
+
"exit codes:\n"
|
|
186
|
+
" 0 verification completed and passed\n"
|
|
187
|
+
" 1 verification could not be completed\n"
|
|
188
|
+
" 2 verification completed and failed\n"
|
|
189
|
+
"\n"
|
|
190
|
+
"examples:\n"
|
|
191
|
+
" visual-verifier doctor\n"
|
|
192
|
+
" visual-verifier inspect examples/media/video_raw.mp4\n"
|
|
193
|
+
" visual-verifier video --reference raw.mp4 --candidate blurred.mp4\n"
|
|
194
|
+
" visual-verifier video --reference raw.mp4 --candidate out.mp4 "
|
|
195
|
+
"--output outputs/run --json\n"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_doctor() -> int:
|
|
200
|
+
"""Print dependency, platform, and codec capability information.
|
|
201
|
+
|
|
202
|
+
Annotated-video output is the only capability that depends on how
|
|
203
|
+
OpenCV was built, so the check probes it rather than assuming it.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Process exit code. Always ``0``; a missing codec is reported as a
|
|
207
|
+
degraded environment because verification itself still works.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
supported_codec_str = find_supported_codec()
|
|
211
|
+
print("Visual Verifier environment check")
|
|
212
|
+
print(DOCTOR_DIVIDER_TEXT)
|
|
213
|
+
print(f"Visual Verifier: {__version__}")
|
|
214
|
+
print(f"Python: {platform.python_version()}")
|
|
215
|
+
print(f"Platform: {platform.platform()}")
|
|
216
|
+
print(f"OpenCV: {cv2.__version__}")
|
|
217
|
+
print(f"NumPy: {np.__version__}")
|
|
218
|
+
print(f"pandas: {pd.__version__}")
|
|
219
|
+
print(f"Video codec: {supported_codec_str or MISSING_CODEC_TEXT}")
|
|
220
|
+
print()
|
|
221
|
+
if supported_codec_str is None:
|
|
222
|
+
print(DEGRADED_STATUS_TEXT)
|
|
223
|
+
else:
|
|
224
|
+
print(HEALTHY_STATUS_TEXT)
|
|
225
|
+
return EXIT_SUCCESS_INT
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def run_demo(arguments_namespace: argparse.Namespace) -> int:
|
|
229
|
+
"""Generate a sample anonymization failure and verify it.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
arguments_namespace: Parsed ``demo`` command arguments.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Process exit code. ``0`` when the demonstration found exactly the
|
|
236
|
+
gaps it created, and ``1`` when it did not.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
print("Visual Verifier demonstration")
|
|
240
|
+
print(DOCTOR_DIVIDER_TEXT)
|
|
241
|
+
print(DEMO_INTRODUCTION_TEXT)
|
|
242
|
+
demonstration_obj = run_demonstration(arguments_namespace.output)
|
|
243
|
+
print(format_verification_summary(demonstration_obj.verification_result))
|
|
244
|
+
print()
|
|
245
|
+
if not demonstration_obj.behaved_as_documented:
|
|
246
|
+
print(DEMO_UNEXPECTED_TEXT, file=sys.stderr)
|
|
247
|
+
return EXIT_EXECUTION_ERROR_INT
|
|
248
|
+
print(DEMO_SUCCESS_TEXT)
|
|
249
|
+
_print_demonstration_reproduction(demonstration_obj)
|
|
250
|
+
return EXIT_SUCCESS_INT
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _print_demonstration_reproduction(
|
|
254
|
+
demonstration_obj: DemonstrationResult,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Print the exact command that reproduces the demonstration.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
demonstration_obj: Completed demonstration and its artefact paths.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
print()
|
|
263
|
+
print("Reproduce it yourself:")
|
|
264
|
+
print(" visual-verifier video \\")
|
|
265
|
+
print(f" --reference {demonstration_obj.reference_path} \\")
|
|
266
|
+
print(f" --candidate {demonstration_obj.candidate_path} \\")
|
|
267
|
+
print(f" --output {demonstration_obj.report_directory}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_inspect(arguments_namespace: argparse.Namespace) -> int:
|
|
271
|
+
"""Inspect one media file and print normalized metadata.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
arguments_namespace: Parsed ``inspect`` command arguments.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Process exit code for the completed inspection.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
metadata_obj = _read_requested_metadata(arguments_namespace.path)
|
|
281
|
+
_print_json(metadata_obj.to_dict())
|
|
282
|
+
return EXIT_SUCCESS_INT
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_image(arguments_namespace: argparse.Namespace) -> int:
|
|
286
|
+
"""Verify one processed image and report the result.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
arguments_namespace: Parsed ``image`` command arguments.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Process exit code derived from the verification status.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
result_obj = verify_image(
|
|
296
|
+
arguments_namespace.reference,
|
|
297
|
+
arguments_namespace.candidate,
|
|
298
|
+
output_dir=arguments_namespace.output,
|
|
299
|
+
expect_processing=not arguments_namespace.allow_no_processing,
|
|
300
|
+
config=_build_detection_config(arguments_namespace),
|
|
301
|
+
)
|
|
302
|
+
_emit_verification_result(result_obj, arguments_namespace)
|
|
303
|
+
return _verification_exit_code(result_obj)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def run_video(arguments_namespace: argparse.Namespace) -> int:
|
|
307
|
+
"""Verify one processed video and report temporal evidence.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
arguments_namespace: Parsed ``video`` command arguments.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Process exit code derived from the verification status.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
result_obj = verify_video(
|
|
317
|
+
arguments_namespace.reference,
|
|
318
|
+
arguments_namespace.candidate,
|
|
319
|
+
output_dir=arguments_namespace.output,
|
|
320
|
+
expect_processing_every_frame=(
|
|
321
|
+
not arguments_namespace.allow_unprocessed_frames
|
|
322
|
+
),
|
|
323
|
+
save_annotated_video=(not arguments_namespace.no_annotated_video),
|
|
324
|
+
save_html_report=(not arguments_namespace.no_html_report),
|
|
325
|
+
config=_build_detection_config(arguments_namespace),
|
|
326
|
+
enable_tracking=(not arguments_namespace.no_tracking),
|
|
327
|
+
tracking_config=_build_tracking_config(arguments_namespace),
|
|
328
|
+
targets=arguments_namespace.targets,
|
|
329
|
+
target_config=_build_target_config(arguments_namespace),
|
|
330
|
+
)
|
|
331
|
+
_emit_verification_result(result_obj, arguments_namespace)
|
|
332
|
+
return _verification_exit_code(result_obj)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _build_target_config(
|
|
336
|
+
arguments_namespace: argparse.Namespace,
|
|
337
|
+
) -> TargetConfig:
|
|
338
|
+
"""Build validated target settings from video CLI arguments.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
arguments_namespace: Parsed ``video`` command arguments.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Immutable target configuration for the requested run.
|
|
345
|
+
|
|
346
|
+
Raises:
|
|
347
|
+
ConfigurationError: When a supplied value is invalid.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
return TargetConfig(
|
|
351
|
+
min_covered_ratio=arguments_namespace.target_min_coverage,
|
|
352
|
+
interpolate_missing_frames=(
|
|
353
|
+
not arguments_namespace.no_target_interpolation
|
|
354
|
+
),
|
|
355
|
+
max_interpolation_gap=arguments_namespace.target_max_gap,
|
|
356
|
+
fail_on_uncovered_target=(
|
|
357
|
+
not arguments_namespace.allow_uncovered_targets
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _build_detection_config(
|
|
363
|
+
arguments_namespace: argparse.Namespace,
|
|
364
|
+
) -> DetectionConfig:
|
|
365
|
+
"""Build validated detection thresholds from shared CLI arguments.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
arguments_namespace: Parsed verification-command arguments.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Immutable detection configuration for the requested run.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ConfigurationError: When a supplied threshold is invalid.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
return DetectionConfig(
|
|
378
|
+
diff_threshold=arguments_namespace.diff_threshold,
|
|
379
|
+
min_box_area=arguments_namespace.min_box_area,
|
|
380
|
+
min_changed_ratio=arguments_namespace.min_changed_ratio,
|
|
381
|
+
min_mean_diff=arguments_namespace.min_mean_diff,
|
|
382
|
+
min_severity_score=arguments_namespace.min_severity,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _build_tracking_config(
|
|
387
|
+
arguments_namespace: argparse.Namespace,
|
|
388
|
+
) -> TrackingConfig:
|
|
389
|
+
"""Build validated tracking settings from video CLI arguments.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
arguments_namespace: Parsed ``video`` command arguments.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Immutable temporal tracking configuration for the requested run.
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
ConfigurationError: When a supplied threshold is invalid.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
return TrackingConfig(
|
|
402
|
+
association_iou_threshold=arguments_namespace.tracking_iou,
|
|
403
|
+
minimum_confirmation_hits=(
|
|
404
|
+
arguments_namespace.tracking_confirmation_hits
|
|
405
|
+
),
|
|
406
|
+
maximum_gap_frames=arguments_namespace.tracking_max_gap,
|
|
407
|
+
lineage_overlap_threshold=(arguments_namespace.lineage_overlap),
|
|
408
|
+
detect_lineage_events=(not arguments_namespace.no_lineage_events),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
413
|
+
"""Build the complete command-line argument parser.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Parser exposing the doctor, inspect, image, and video commands.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
parser_obj = argparse.ArgumentParser(
|
|
420
|
+
prog="visual-verifier",
|
|
421
|
+
description=CLI_DESCRIPTION_TEXT,
|
|
422
|
+
epilog=CLI_EPILOG_TEXT,
|
|
423
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
424
|
+
)
|
|
425
|
+
parser_obj.add_argument(
|
|
426
|
+
"--version",
|
|
427
|
+
action="version",
|
|
428
|
+
version=f"%(prog)s {__version__}",
|
|
429
|
+
help="Print the installed Visual Verifier version and exit.",
|
|
430
|
+
)
|
|
431
|
+
subparsers_action = parser_obj.add_subparsers(
|
|
432
|
+
dest="command",
|
|
433
|
+
metavar="command",
|
|
434
|
+
required=True,
|
|
435
|
+
help="Verification and diagnostic command to run.",
|
|
436
|
+
)
|
|
437
|
+
_add_doctor_parser(subparsers_action)
|
|
438
|
+
_add_demo_parser(subparsers_action)
|
|
439
|
+
_add_inspect_parser(subparsers_action)
|
|
440
|
+
_add_image_parser(subparsers_action)
|
|
441
|
+
_add_video_parser(subparsers_action)
|
|
442
|
+
return parser_obj
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
446
|
+
"""Run the Visual Verifier command-line interface.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
argv: Optional argument vector. Defaults to ``sys.argv[1:]``.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Process exit code. ``0`` passed, ``1`` execution error, and ``2``
|
|
453
|
+
verification failed.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
parser_obj = build_parser()
|
|
457
|
+
arguments_namespace = parser_obj.parse_args(argv)
|
|
458
|
+
try:
|
|
459
|
+
return _dispatch_command(arguments_namespace, parser_obj)
|
|
460
|
+
except VisualVerifierError as domain_error_obj:
|
|
461
|
+
_print_structured_error(domain_error_obj)
|
|
462
|
+
return EXIT_EXECUTION_ERROR_INT
|
|
463
|
+
except Exception as unexpected_error_obj:
|
|
464
|
+
_print_unexpected_error(unexpected_error_obj)
|
|
465
|
+
return EXIT_EXECUTION_ERROR_INT
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _dispatch_command(
|
|
469
|
+
arguments_namespace: argparse.Namespace,
|
|
470
|
+
parser_obj: argparse.ArgumentParser,
|
|
471
|
+
) -> int:
|
|
472
|
+
"""Dispatch parsed arguments to the requested command handler.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
arguments_namespace: Parsed command-line arguments.
|
|
476
|
+
parser_obj: Parser used to print help for unknown commands.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Process exit code produced by the selected command.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
command_str = str(arguments_namespace.command)
|
|
483
|
+
if command_str == "doctor":
|
|
484
|
+
return run_doctor()
|
|
485
|
+
if command_str == "demo":
|
|
486
|
+
return run_demo(arguments_namespace)
|
|
487
|
+
if command_str == "inspect":
|
|
488
|
+
return run_inspect(arguments_namespace)
|
|
489
|
+
if command_str == "image":
|
|
490
|
+
return run_image(arguments_namespace)
|
|
491
|
+
if command_str == "video":
|
|
492
|
+
return run_video(arguments_namespace)
|
|
493
|
+
parser_obj.print_help()
|
|
494
|
+
return EXIT_EXECUTION_ERROR_INT
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _read_requested_metadata(path_input: str) -> MediaMetadata:
|
|
498
|
+
"""Read image or video metadata based on the filename suffix.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
path_input: User-supplied media path.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Normalized metadata for the inspected media file.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
if _suffix_from_path(path_input) in VIDEO_SUFFIXES_FROZENSET:
|
|
508
|
+
return read_video_metadata(path_input)
|
|
509
|
+
return read_image_metadata(path_input)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _suffix_from_path(path_input: str) -> str:
|
|
513
|
+
"""Return a lowercase filename suffix without resolving the path.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
path_input: User-supplied media path.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Lowercase suffix including the leading dot, or an empty string.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
return Path(path_input).suffix.lower()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _emit_verification_result(
|
|
526
|
+
result_obj: VerificationResult,
|
|
527
|
+
arguments_namespace: argparse.Namespace,
|
|
528
|
+
) -> None:
|
|
529
|
+
"""Print one verification result in the requested output format.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
result_obj: Completed verification result.
|
|
533
|
+
arguments_namespace: Parsed arguments carrying output options.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
if arguments_namespace.quiet:
|
|
537
|
+
return
|
|
538
|
+
if arguments_namespace.json_output:
|
|
539
|
+
_print_json(result_obj.to_dict())
|
|
540
|
+
return
|
|
541
|
+
print(format_verification_summary(result_obj))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _print_json(payload_mapping: Mapping[str, object]) -> None:
|
|
545
|
+
"""Print a mapping as consistently formatted JSON.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
payload_mapping: Serializable payload to print on standard output.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
print(json.dumps(payload_mapping, indent=2, default=str))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _print_structured_error(error_obj: VisualVerifierError) -> None:
|
|
555
|
+
"""Print a structured Visual Verifier error on standard error.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
error_obj: Raised domain error carrying a code and context.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
print(
|
|
562
|
+
f"ERROR [{error_obj.error_code}]: {error_obj.message_str}",
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
for context_name_str, context_value_obj in sorted(
|
|
566
|
+
error_obj.context_dict.items()
|
|
567
|
+
):
|
|
568
|
+
print(f" {context_name_str}: {context_value_obj}", file=sys.stderr)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _print_unexpected_error(error_obj: Exception) -> None:
|
|
572
|
+
"""Print an unexpected failure on standard error.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
error_obj: Exception raised outside the documented error model.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
print(
|
|
579
|
+
f"ERROR [UNEXPECTED_ERROR] {type(error_obj).__name__}: {error_obj}",
|
|
580
|
+
file=sys.stderr,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _verification_exit_code(result_obj: VerificationResult) -> int:
|
|
585
|
+
"""Convert a verification result into a process exit code.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
result_obj: Completed verification result.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
``0`` when the result passed and ``2`` when it failed.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
if result_obj.passed:
|
|
595
|
+
return EXIT_SUCCESS_INT
|
|
596
|
+
return EXIT_VERIFICATION_FAILED_INT
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _add_doctor_parser(
|
|
600
|
+
subparsers_action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Add the environment-check command parser.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
subparsers_action: Subparser registry for the root parser.
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
subparsers_action.add_parser(
|
|
609
|
+
"doctor",
|
|
610
|
+
help="Check the local environment and print dependency versions.",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _add_demo_parser(
|
|
615
|
+
subparsers_action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
616
|
+
) -> None:
|
|
617
|
+
"""Add the self-contained demonstration command parser.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
subparsers_action: Subparser registry for the root parser.
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
demo_parser_obj = subparsers_action.add_parser(
|
|
624
|
+
"demo",
|
|
625
|
+
help="Generate a sample anonymization failure and verify it.",
|
|
626
|
+
)
|
|
627
|
+
demo_parser_obj.add_argument(
|
|
628
|
+
"--output",
|
|
629
|
+
default=None,
|
|
630
|
+
metavar="DIR",
|
|
631
|
+
help=(
|
|
632
|
+
"Directory for the generated sample media and evidence "
|
|
633
|
+
"(default: ./visual-verifier-demo)."
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _add_inspect_parser(
|
|
639
|
+
subparsers_action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Add the media-inspection command parser.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
subparsers_action: Subparser registry for the root parser.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
inspect_parser_obj = subparsers_action.add_parser(
|
|
648
|
+
"inspect",
|
|
649
|
+
help="Print normalized metadata for one image or video file.",
|
|
650
|
+
)
|
|
651
|
+
inspect_parser_obj.add_argument(
|
|
652
|
+
"path",
|
|
653
|
+
help="Path to the image or video file to inspect.",
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _add_image_parser(
|
|
658
|
+
subparsers_action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
659
|
+
) -> None:
|
|
660
|
+
"""Add the image-verification command parser.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
subparsers_action: Subparser registry for the root parser.
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
image_parser_obj = subparsers_action.add_parser(
|
|
667
|
+
"image",
|
|
668
|
+
help="Verify one processed image against its reference.",
|
|
669
|
+
)
|
|
670
|
+
_add_common_verification_arguments(image_parser_obj)
|
|
671
|
+
image_parser_obj.add_argument(
|
|
672
|
+
"--allow-no-processing",
|
|
673
|
+
action="store_true",
|
|
674
|
+
help=(
|
|
675
|
+
"Pass even when no accepted changed region is detected. Use "
|
|
676
|
+
"this when processing is optional for the candidate."
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _add_video_parser(
|
|
682
|
+
subparsers_action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
683
|
+
) -> None:
|
|
684
|
+
"""Add video verification and temporal tracking arguments.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
subparsers_action: Subparser registry for the root parser.
|
|
688
|
+
"""
|
|
689
|
+
|
|
690
|
+
video_parser_obj = subparsers_action.add_parser(
|
|
691
|
+
"video",
|
|
692
|
+
help="Verify one processed video with temporal tracking evidence.",
|
|
693
|
+
)
|
|
694
|
+
_add_common_verification_arguments(video_parser_obj)
|
|
695
|
+
video_parser_obj.add_argument(
|
|
696
|
+
"--allow-unprocessed-frames",
|
|
697
|
+
action="store_true",
|
|
698
|
+
help=(
|
|
699
|
+
"Pass even when some synchronized frames contain no accepted "
|
|
700
|
+
"changed region."
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
video_parser_obj.add_argument(
|
|
704
|
+
"--no-annotated-video",
|
|
705
|
+
action="store_true",
|
|
706
|
+
help="Skip writing annotated_video.mp4 evidence.",
|
|
707
|
+
)
|
|
708
|
+
video_parser_obj.add_argument(
|
|
709
|
+
"--no-html-report",
|
|
710
|
+
action="store_true",
|
|
711
|
+
help="Skip writing the self-contained index.html report.",
|
|
712
|
+
)
|
|
713
|
+
video_parser_obj.add_argument(
|
|
714
|
+
"--no-tracking",
|
|
715
|
+
action="store_true",
|
|
716
|
+
help="Skip temporal tracking. Frame PASS/FAIL stays unchanged.",
|
|
717
|
+
)
|
|
718
|
+
_add_tracking_threshold_arguments(video_parser_obj)
|
|
719
|
+
_add_target_arguments(video_parser_obj)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _add_common_verification_arguments(
|
|
723
|
+
verification_parser_obj: argparse.ArgumentParser,
|
|
724
|
+
) -> None:
|
|
725
|
+
"""Add input, output, and detection arguments shared by both commands.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
verification_parser_obj: Image or video subcommand parser.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
verification_parser_obj.add_argument(
|
|
732
|
+
"--reference",
|
|
733
|
+
required=True,
|
|
734
|
+
metavar="PATH",
|
|
735
|
+
help="Path to the original, unprocessed reference media.",
|
|
736
|
+
)
|
|
737
|
+
verification_parser_obj.add_argument(
|
|
738
|
+
"--candidate",
|
|
739
|
+
required=True,
|
|
740
|
+
metavar="PATH",
|
|
741
|
+
help="Path to the processed candidate media being verified.",
|
|
742
|
+
)
|
|
743
|
+
verification_parser_obj.add_argument(
|
|
744
|
+
"--output",
|
|
745
|
+
default=None,
|
|
746
|
+
metavar="DIR",
|
|
747
|
+
help=(
|
|
748
|
+
"Directory for reports and annotated evidence. Omit to verify "
|
|
749
|
+
"without writing any files."
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
_add_output_format_arguments(verification_parser_obj)
|
|
753
|
+
_add_detection_threshold_arguments(verification_parser_obj)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _add_output_format_arguments(
|
|
757
|
+
verification_parser_obj: argparse.ArgumentParser,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Add console output-format arguments shared by both commands.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
verification_parser_obj: Image or video subcommand parser.
|
|
763
|
+
"""
|
|
764
|
+
|
|
765
|
+
verification_parser_obj.add_argument(
|
|
766
|
+
"--json",
|
|
767
|
+
dest="json_output",
|
|
768
|
+
action="store_true",
|
|
769
|
+
help="Print the full machine-readable result as JSON.",
|
|
770
|
+
)
|
|
771
|
+
verification_parser_obj.add_argument(
|
|
772
|
+
"-q",
|
|
773
|
+
"--quiet",
|
|
774
|
+
action="store_true",
|
|
775
|
+
help="Suppress standard output and report only the exit code.",
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _add_detection_threshold_arguments(
|
|
780
|
+
verification_parser_obj: argparse.ArgumentParser,
|
|
781
|
+
) -> None:
|
|
782
|
+
"""Add region-detection threshold arguments shared by both commands.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
verification_parser_obj: Image or video subcommand parser.
|
|
786
|
+
"""
|
|
787
|
+
|
|
788
|
+
_add_numeric_arguments(
|
|
789
|
+
verification_parser_obj,
|
|
790
|
+
DETECTION_ARGUMENT_SPECS_TUPLE,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _add_tracking_threshold_arguments(
|
|
795
|
+
video_parser_obj: argparse.ArgumentParser,
|
|
796
|
+
) -> None:
|
|
797
|
+
"""Add temporal association and lifecycle threshold arguments.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
video_parser_obj: Video subcommand parser.
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
_add_numeric_arguments(video_parser_obj, TRACKING_ARGUMENT_SPECS_TUPLE)
|
|
804
|
+
video_parser_obj.add_argument(
|
|
805
|
+
"--no-lineage-events",
|
|
806
|
+
action="store_true",
|
|
807
|
+
help="Keep tracking but omit split and merge lineage evidence.",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _add_target_arguments(
|
|
812
|
+
video_parser_obj: argparse.ArgumentParser,
|
|
813
|
+
) -> None:
|
|
814
|
+
"""Add reviewed-target arguments to the video subcommand.
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
video_parser_obj: Video subcommand parser.
|
|
818
|
+
"""
|
|
819
|
+
|
|
820
|
+
video_parser_obj.add_argument(
|
|
821
|
+
"--targets",
|
|
822
|
+
metavar="PATH",
|
|
823
|
+
default=None,
|
|
824
|
+
help=(
|
|
825
|
+
"Reviewed target CSV declaring regions that must be "
|
|
826
|
+
"anonymized. Supplying it changes the question from "
|
|
827
|
+
"whether anything changed to whether the required region "
|
|
828
|
+
"changed, so it can change the verdict."
|
|
829
|
+
),
|
|
830
|
+
)
|
|
831
|
+
_add_numeric_arguments(video_parser_obj, TARGET_ARGUMENT_SPECS_TUPLE)
|
|
832
|
+
video_parser_obj.add_argument(
|
|
833
|
+
"--no-target-interpolation",
|
|
834
|
+
action="store_true",
|
|
835
|
+
help=(
|
|
836
|
+
"Use only reviewed target boxes. Frames between two "
|
|
837
|
+
"reviewed boxes are left unchecked instead of interpolated."
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
video_parser_obj.add_argument(
|
|
841
|
+
"--allow-uncovered-targets",
|
|
842
|
+
action="store_true",
|
|
843
|
+
help=(
|
|
844
|
+
"Report target coverage as evidence without letting an "
|
|
845
|
+
"uncovered target fail verification."
|
|
846
|
+
),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _add_numeric_arguments(
|
|
851
|
+
parser_obj: argparse.ArgumentParser,
|
|
852
|
+
argument_specs_tuple: tuple[_NumericArgumentSpec, ...],
|
|
853
|
+
) -> None:
|
|
854
|
+
"""Register every described numeric threshold on one parser.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
parser_obj: Subcommand parser receiving the arguments.
|
|
858
|
+
argument_specs_tuple: Ordered threshold descriptions.
|
|
859
|
+
"""
|
|
860
|
+
|
|
861
|
+
for argument_spec_obj in argument_specs_tuple:
|
|
862
|
+
parser_obj.add_argument(
|
|
863
|
+
argument_spec_obj.flag_text,
|
|
864
|
+
type=argument_spec_obj.value_type,
|
|
865
|
+
default=argument_spec_obj.default_value,
|
|
866
|
+
metavar=argument_spec_obj.metavar_text,
|
|
867
|
+
help=argument_spec_obj.help_text,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
__all__ = [
|
|
872
|
+
"build_parser",
|
|
873
|
+
"main",
|
|
874
|
+
"run_demo",
|
|
875
|
+
"run_doctor",
|
|
876
|
+
"run_image",
|
|
877
|
+
"run_inspect",
|
|
878
|
+
"run_video",
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
if __name__ == "__main__":
|
|
883
|
+
raise SystemExit(main())
|