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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Expose supported Visual Verifier configuration interfaces."""
|
|
2
|
+
|
|
3
|
+
from visual_verifier.config.defaults import DEFAULT_DETECTION_CONFIG
|
|
4
|
+
from visual_verifier.config.tracking import (
|
|
5
|
+
DEFAULT_TRACKING_CONFIG,
|
|
6
|
+
TrackingConfig,
|
|
7
|
+
)
|
|
8
|
+
from visual_verifier.models import DetectionConfig
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_DETECTION_CONFIG",
|
|
12
|
+
"DEFAULT_TRACKING_CONFIG",
|
|
13
|
+
"DetectionConfig",
|
|
14
|
+
"TrackingConfig",
|
|
15
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Define immutable default values for visual region verification."""
|
|
2
|
+
|
|
3
|
+
from visual_verifier.models import DetectionConfig
|
|
4
|
+
|
|
5
|
+
DEFAULT_DIFF_THRESHOLD_INT = 30
|
|
6
|
+
DEFAULT_MIN_BOX_AREA_INT = 80
|
|
7
|
+
DEFAULT_MIN_BOX_WIDTH_INT = 6
|
|
8
|
+
DEFAULT_MIN_BOX_HEIGHT_INT = 6
|
|
9
|
+
DEFAULT_MERGE_KERNEL_SIZE_INT = 9
|
|
10
|
+
DEFAULT_BOX_PADDING_INT = 3
|
|
11
|
+
DEFAULT_MIN_CHANGED_RATIO_FLOAT = 0.03
|
|
12
|
+
DEFAULT_MIN_MEAN_DIFF_FLOAT = 5.0
|
|
13
|
+
DEFAULT_MIN_SEVERITY_SCORE_FLOAT = 8.0
|
|
14
|
+
DEFAULT_DIFF_NORMALIZER_FLOAT = 80.0
|
|
15
|
+
DEFAULT_CHANGED_RATIO_NORMALIZER_FLOAT = 0.75
|
|
16
|
+
|
|
17
|
+
DEFAULT_DETECTION_CONFIG = DetectionConfig(
|
|
18
|
+
diff_threshold=DEFAULT_DIFF_THRESHOLD_INT,
|
|
19
|
+
min_box_area=DEFAULT_MIN_BOX_AREA_INT,
|
|
20
|
+
min_box_width=DEFAULT_MIN_BOX_WIDTH_INT,
|
|
21
|
+
min_box_height=DEFAULT_MIN_BOX_HEIGHT_INT,
|
|
22
|
+
merge_kernel_size=DEFAULT_MERGE_KERNEL_SIZE_INT,
|
|
23
|
+
box_padding=DEFAULT_BOX_PADDING_INT,
|
|
24
|
+
min_changed_ratio=DEFAULT_MIN_CHANGED_RATIO_FLOAT,
|
|
25
|
+
min_mean_diff=DEFAULT_MIN_MEAN_DIFF_FLOAT,
|
|
26
|
+
min_severity_score=DEFAULT_MIN_SEVERITY_SCORE_FLOAT,
|
|
27
|
+
diff_normalizer=DEFAULT_DIFF_NORMALIZER_FLOAT,
|
|
28
|
+
changed_ratio_normalizer=(DEFAULT_CHANGED_RATIO_NORMALIZER_FLOAT),
|
|
29
|
+
)
|
|
30
|
+
"""Validated immutable defaults used by public verification calls."""
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DEFAULT_BOX_PADDING_INT",
|
|
34
|
+
"DEFAULT_CHANGED_RATIO_NORMALIZER_FLOAT",
|
|
35
|
+
"DEFAULT_DETECTION_CONFIG",
|
|
36
|
+
"DEFAULT_DIFF_NORMALIZER_FLOAT",
|
|
37
|
+
"DEFAULT_DIFF_THRESHOLD_INT",
|
|
38
|
+
"DEFAULT_MERGE_KERNEL_SIZE_INT",
|
|
39
|
+
"DEFAULT_MIN_BOX_AREA_INT",
|
|
40
|
+
"DEFAULT_MIN_BOX_HEIGHT_INT",
|
|
41
|
+
"DEFAULT_MIN_BOX_WIDTH_INT",
|
|
42
|
+
"DEFAULT_MIN_CHANGED_RATIO_FLOAT",
|
|
43
|
+
"DEFAULT_MIN_MEAN_DIFF_FLOAT",
|
|
44
|
+
"DEFAULT_MIN_SEVERITY_SCORE_FLOAT",
|
|
45
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configure target-aware verification.
|
|
2
|
+
|
|
3
|
+
The defaults are deliberately strict. A target is the reviewer's
|
|
4
|
+
statement that a specific region had to be anonymized, so accepting
|
|
5
|
+
partial coverage by default would quietly reintroduce the failure this
|
|
6
|
+
feature exists to catch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from visual_verifier.exceptions import ConfigurationError
|
|
15
|
+
|
|
16
|
+
MINIMUM_RATIO_FLOAT: Final[float] = 0.0
|
|
17
|
+
MAXIMUM_RATIO_FLOAT: Final[float] = 1.0
|
|
18
|
+
MAXIMUM_SENSIBLE_GAP_INT: Final[int] = 300
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class TargetConfig:
|
|
23
|
+
"""Configure how targets are interpolated and scored.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
min_covered_ratio: Fraction of a target's area that accepted
|
|
27
|
+
processing must cover before the target counts as
|
|
28
|
+
anonymized.
|
|
29
|
+
interpolate_missing_frames: Fill frames between two reviewed
|
|
30
|
+
boxes of the same target.
|
|
31
|
+
max_interpolation_gap: Longest run of missing frames that may be
|
|
32
|
+
interpolated. A longer gap is left empty, because a target
|
|
33
|
+
can leave and re-enter a scene and inventing boxes across
|
|
34
|
+
that would fabricate evidence.
|
|
35
|
+
fail_on_uncovered_target: Let an uncovered required target fail
|
|
36
|
+
verification. Turn it off to collect target evidence without
|
|
37
|
+
changing the verdict.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
min_covered_ratio: float = 0.9
|
|
41
|
+
interpolate_missing_frames: bool = True
|
|
42
|
+
max_interpolation_gap: int = 5
|
|
43
|
+
fail_on_uncovered_target: bool = True
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Validate the configuration immediately after construction.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ConfigurationError: When a value cannot be applied safely.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
self._validate_covered_ratio()
|
|
53
|
+
self._validate_interpolation_gap()
|
|
54
|
+
|
|
55
|
+
def _validate_covered_ratio(self) -> None:
|
|
56
|
+
"""Validate the minimum coverage ratio.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ConfigurationError: When the ratio is outside zero to one.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
if not (
|
|
63
|
+
MINIMUM_RATIO_FLOAT < self.min_covered_ratio <= MAXIMUM_RATIO_FLOAT
|
|
64
|
+
):
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
"Target coverage ratio must be above 0 and at most 1.",
|
|
67
|
+
context_mapping={
|
|
68
|
+
"min_covered_ratio": self.min_covered_ratio,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _validate_interpolation_gap(self) -> None:
|
|
73
|
+
"""Validate the interpolation gap limit.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ConfigurationError: When the gap is negative or implausible.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if self.max_interpolation_gap < 0:
|
|
80
|
+
raise ConfigurationError(
|
|
81
|
+
"Target interpolation gap cannot be negative.",
|
|
82
|
+
context_mapping={
|
|
83
|
+
"max_interpolation_gap": self.max_interpolation_gap,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
if self.max_interpolation_gap > MAXIMUM_SENSIBLE_GAP_INT:
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
"Target interpolation gap is implausibly long.",
|
|
89
|
+
context_mapping={
|
|
90
|
+
"max_interpolation_gap": self.max_interpolation_gap,
|
|
91
|
+
"maximum_supported": MAXIMUM_SENSIBLE_GAP_INT,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
DEFAULT_TARGET_CONFIG: Final[TargetConfig] = TargetConfig()
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"DEFAULT_TARGET_CONFIG",
|
|
100
|
+
"TargetConfig",
|
|
101
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Define immutable configuration for temporal region tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from visual_verifier.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
DEFAULT_ASSOCIATION_IOU_THRESHOLD_FLOAT = 0.20
|
|
10
|
+
DEFAULT_MINIMUM_CONFIRMATION_HITS_INT = 2
|
|
11
|
+
DEFAULT_MAXIMUM_GAP_FRAMES_INT = 3
|
|
12
|
+
DEFAULT_LINEAGE_OVERLAP_THRESHOLD_FLOAT = 0.20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class TrackingConfig:
|
|
17
|
+
"""Configure deterministic temporal association and track lifecycle.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
association_iou_threshold: Minimum intersection-over-union required
|
|
21
|
+
to associate one detection with an active track.
|
|
22
|
+
minimum_confirmation_hits: Consecutive or recovered observations
|
|
23
|
+
required before a tentative track becomes confirmed.
|
|
24
|
+
maximum_gap_frames: Number of consecutive missing frames tolerated
|
|
25
|
+
before an active track is closed.
|
|
26
|
+
lineage_overlap_threshold: Minimum overlap used when detecting
|
|
27
|
+
split and merge lineage events.
|
|
28
|
+
detect_lineage_events: Whether split and merge evidence is produced.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
association_iou_threshold: float = DEFAULT_ASSOCIATION_IOU_THRESHOLD_FLOAT
|
|
32
|
+
minimum_confirmation_hits: int = DEFAULT_MINIMUM_CONFIRMATION_HITS_INT
|
|
33
|
+
maximum_gap_frames: int = DEFAULT_MAXIMUM_GAP_FRAMES_INT
|
|
34
|
+
lineage_overlap_threshold: float = DEFAULT_LINEAGE_OVERLAP_THRESHOLD_FLOAT
|
|
35
|
+
detect_lineage_events: bool = True
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
"""Validate temporal tracking settings after construction.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ConfigurationError: When one or more settings are invalid.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
invalid_fields_list = self._find_invalid_fields()
|
|
45
|
+
if not invalid_fields_list:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
"Tracking configuration contains invalid values.",
|
|
50
|
+
context_mapping={
|
|
51
|
+
"invalid_fields": sorted(set(invalid_fields_list)),
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _find_invalid_fields(self) -> list[str]:
|
|
56
|
+
"""Return configuration field names that fail validation.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Names of invalid threshold or lifecycle settings.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
invalid_fields_list: list[str] = []
|
|
63
|
+
|
|
64
|
+
if not 0.0 <= self.association_iou_threshold <= 1.0:
|
|
65
|
+
invalid_fields_list.append("association_iou_threshold")
|
|
66
|
+
if self.minimum_confirmation_hits < 1:
|
|
67
|
+
invalid_fields_list.append("minimum_confirmation_hits")
|
|
68
|
+
if self.maximum_gap_frames < 0:
|
|
69
|
+
invalid_fields_list.append("maximum_gap_frames")
|
|
70
|
+
if not 0.0 <= self.lineage_overlap_threshold <= 1.0:
|
|
71
|
+
invalid_fields_list.append("lineage_overlap_threshold")
|
|
72
|
+
|
|
73
|
+
return invalid_fields_list
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, object]:
|
|
76
|
+
"""Return serializable temporal tracking settings.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dictionary containing each configuration field.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
field_name_str: getattr(self, field_name_str)
|
|
84
|
+
for field_name_str in self.__dataclass_fields__
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DEFAULT_TRACKING_CONFIG = TrackingConfig()
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"DEFAULT_ASSOCIATION_IOU_THRESHOLD_FLOAT",
|
|
92
|
+
"DEFAULT_LINEAGE_OVERLAP_THRESHOLD_FLOAT",
|
|
93
|
+
"DEFAULT_MAXIMUM_GAP_FRAMES_INT",
|
|
94
|
+
"DEFAULT_MINIMUM_CONFIRMATION_HITS_INT",
|
|
95
|
+
"DEFAULT_TRACKING_CONFIG",
|
|
96
|
+
"TrackingConfig",
|
|
97
|
+
]
|
visual_verifier/demo.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Generate and verify a self-contained demonstration.
|
|
2
|
+
|
|
3
|
+
A new user should be able to see a real anonymization failure without
|
|
4
|
+
cloning the repository, downloading fixtures, or supplying media of their
|
|
5
|
+
own. This module synthesizes a short scene in which a licence plate is
|
|
6
|
+
blurred on every frame except three, then verifies it.
|
|
7
|
+
|
|
8
|
+
The sample is generated rather than shipped: it keeps the wheel small,
|
|
9
|
+
needs no network access, and is reproducible on every platform. The clip
|
|
10
|
+
is deliberately synthetic, and the command says so.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import cv2
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from visual_verifier.api import verify_video
|
|
22
|
+
from visual_verifier.media.video_writer import open_annotated_writer
|
|
23
|
+
from visual_verifier.models import BoundingBox, VerificationResult
|
|
24
|
+
from visual_verifier.type_aliases import ImageArray, PathInput
|
|
25
|
+
|
|
26
|
+
DEMONSTRATION_DIRECTORY_NAME_STR = "visual-verifier-demo"
|
|
27
|
+
REFERENCE_FILENAME_STR = "reference.mp4"
|
|
28
|
+
CANDIDATE_FILENAME_STR = "candidate.mp4"
|
|
29
|
+
REPORT_DIRECTORY_NAME_STR = "report"
|
|
30
|
+
|
|
31
|
+
DEMONSTRATION_FRAME_COUNT_INT = 15
|
|
32
|
+
DEMONSTRATION_FRAME_RATE_FLOAT = 2.0
|
|
33
|
+
DEMONSTRATION_WIDTH_INT = 640
|
|
34
|
+
DEMONSTRATION_HEIGHT_INT = 360
|
|
35
|
+
UNPROCESSED_FRAMES_TUPLE = (4, 8, 12)
|
|
36
|
+
|
|
37
|
+
PLATE_WIDTH_INT = 96
|
|
38
|
+
PLATE_HEIGHT_INT = 40
|
|
39
|
+
PLATE_START_X_INT = 210
|
|
40
|
+
PLATE_TOP_Y_INT = 232
|
|
41
|
+
PLATE_STEP_X_INT = 14
|
|
42
|
+
PLATE_BLUR_PADDING_INT = 6
|
|
43
|
+
PLATE_BLUR_KERNEL_TUPLE = (31, 31)
|
|
44
|
+
PLATE_CHARACTER_COUNT_INT = 6
|
|
45
|
+
|
|
46
|
+
ROAD_TOP_Y_INT = 250
|
|
47
|
+
LANE_MARK_TOP_Y_INT = 320
|
|
48
|
+
LANE_MARK_HEIGHT_INT = 10
|
|
49
|
+
LANE_MARK_WIDTH_INT = 46
|
|
50
|
+
LANE_MARK_SPACING_INT = 90
|
|
51
|
+
LANE_MARK_SPEED_INT = 18
|
|
52
|
+
|
|
53
|
+
ROAD_COLOR_BGR = (70, 70, 74)
|
|
54
|
+
LANE_COLOR_BGR = (225, 225, 225)
|
|
55
|
+
VEHICLE_COLOR_BGR = (48, 44, 52)
|
|
56
|
+
WINDOW_COLOR_BGR = (30, 28, 34)
|
|
57
|
+
PLATE_COLOR_BGR = (242, 242, 238)
|
|
58
|
+
PLATE_CHARACTER_COLOR_BGR = (24, 24, 28)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class DemonstrationResult:
|
|
63
|
+
"""Report where the demonstration wrote its media and evidence.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
reference_path: Generated original clip.
|
|
67
|
+
candidate_path: Generated processed clip with three gaps.
|
|
68
|
+
report_directory: Directory holding the generated evidence.
|
|
69
|
+
verification_result: Result produced by the normal video API.
|
|
70
|
+
expected_unprocessed_frames: Frames deliberately left unblurred.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
reference_path: Path
|
|
74
|
+
candidate_path: Path
|
|
75
|
+
report_directory: Path
|
|
76
|
+
verification_result: VerificationResult
|
|
77
|
+
expected_unprocessed_frames: tuple[int, ...]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def behaved_as_documented(self) -> bool:
|
|
81
|
+
"""Return whether the demonstration found exactly the gaps sown."""
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
self.verification_result.failed
|
|
85
|
+
and self.verification_result.failed_frames
|
|
86
|
+
== self.expected_unprocessed_frames
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_demonstration(
|
|
91
|
+
output_dir: PathInput | None = None,
|
|
92
|
+
) -> DemonstrationResult:
|
|
93
|
+
"""Generate a sample anonymization failure and verify it.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
output_dir: Directory for the generated media and evidence.
|
|
97
|
+
Defaults to ``./visual-verifier-demo``.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Paths to every generated artefact and the verification result.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ReportWriteError: When the sample media cannot be written, which
|
|
104
|
+
usually means this OpenCV build has no usable video codec.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
demonstration_path_obj = Path(
|
|
108
|
+
output_dir or DEMONSTRATION_DIRECTORY_NAME_STR
|
|
109
|
+
).expanduser()
|
|
110
|
+
demonstration_path_obj.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
reference_path_obj = demonstration_path_obj / REFERENCE_FILENAME_STR
|
|
112
|
+
candidate_path_obj = demonstration_path_obj / CANDIDATE_FILENAME_STR
|
|
113
|
+
report_path_obj = demonstration_path_obj / REPORT_DIRECTORY_NAME_STR
|
|
114
|
+
|
|
115
|
+
_write_demonstration_video(reference_path_obj, apply_blur_bool=False)
|
|
116
|
+
_write_demonstration_video(candidate_path_obj, apply_blur_bool=True)
|
|
117
|
+
|
|
118
|
+
return DemonstrationResult(
|
|
119
|
+
reference_path=reference_path_obj,
|
|
120
|
+
candidate_path=candidate_path_obj,
|
|
121
|
+
report_directory=report_path_obj,
|
|
122
|
+
verification_result=verify_video(
|
|
123
|
+
reference_path_obj,
|
|
124
|
+
candidate_path_obj,
|
|
125
|
+
output_dir=report_path_obj,
|
|
126
|
+
),
|
|
127
|
+
expected_unprocessed_frames=UNPROCESSED_FRAMES_TUPLE,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_demonstration_video(
|
|
132
|
+
output_path_obj: Path,
|
|
133
|
+
*,
|
|
134
|
+
apply_blur_bool: bool,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Write one generated clip, optionally blurring the licence plate.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
output_path_obj: Destination MP4 path.
|
|
140
|
+
apply_blur_bool: Blur the plate on every frame except the three
|
|
141
|
+
deliberately unprocessed ones.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ReportWriteError: When no supported codec can be opened.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
video_writer_obj = open_annotated_writer(
|
|
148
|
+
output_path_obj,
|
|
149
|
+
DEMONSTRATION_FRAME_RATE_FLOAT,
|
|
150
|
+
(DEMONSTRATION_WIDTH_INT, DEMONSTRATION_HEIGHT_INT),
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
for frame_number_int in range(1, DEMONSTRATION_FRAME_COUNT_INT + 1):
|
|
154
|
+
frame_ndarray = _render_scene_frame(frame_number_int)
|
|
155
|
+
if apply_blur_bool and (
|
|
156
|
+
frame_number_int not in UNPROCESSED_FRAMES_TUPLE
|
|
157
|
+
):
|
|
158
|
+
_blur_plate_region(frame_ndarray, frame_number_int)
|
|
159
|
+
video_writer_obj.write(frame_ndarray)
|
|
160
|
+
finally:
|
|
161
|
+
video_writer_obj.release()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _render_scene_frame(frame_number_int: int) -> ImageArray:
|
|
165
|
+
"""Render one deterministic frame of the sample scene.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
frame_number_int: One-based frame number.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Rendered frame containing a road, a vehicle, and a plate.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
frame_ndarray: ImageArray = np.zeros(
|
|
175
|
+
(DEMONSTRATION_HEIGHT_INT, DEMONSTRATION_WIDTH_INT, 3),
|
|
176
|
+
dtype=np.uint8,
|
|
177
|
+
)
|
|
178
|
+
_draw_sky_and_road(frame_ndarray)
|
|
179
|
+
_draw_lane_markings(frame_ndarray, frame_number_int)
|
|
180
|
+
_draw_vehicle(frame_ndarray, _plate_box(frame_number_int))
|
|
181
|
+
return frame_ndarray
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _draw_sky_and_road(frame_ndarray: ImageArray) -> None:
|
|
185
|
+
"""Fill the frame with a graded sky above a flat road surface."""
|
|
186
|
+
|
|
187
|
+
for row_index_int in range(DEMONSTRATION_HEIGHT_INT):
|
|
188
|
+
frame_ndarray[row_index_int, :] = (
|
|
189
|
+
170 - row_index_int // 4,
|
|
190
|
+
140 - row_index_int // 6,
|
|
191
|
+
110 - row_index_int // 8,
|
|
192
|
+
)
|
|
193
|
+
cv2.rectangle(
|
|
194
|
+
frame_ndarray,
|
|
195
|
+
(0, ROAD_TOP_Y_INT),
|
|
196
|
+
(DEMONSTRATION_WIDTH_INT, DEMONSTRATION_HEIGHT_INT),
|
|
197
|
+
ROAD_COLOR_BGR,
|
|
198
|
+
thickness=-1,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _draw_lane_markings(
|
|
203
|
+
frame_ndarray: ImageArray,
|
|
204
|
+
frame_number_int: int,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Draw lane markings that advance with the frame number."""
|
|
207
|
+
|
|
208
|
+
total_span_int = DEMONSTRATION_WIDTH_INT + LANE_MARK_SPACING_INT
|
|
209
|
+
for marking_index_int in range(-1, 9):
|
|
210
|
+
left_x_int = (
|
|
211
|
+
marking_index_int * LANE_MARK_SPACING_INT
|
|
212
|
+
+ frame_number_int * LANE_MARK_SPEED_INT
|
|
213
|
+
) % total_span_int - LANE_MARK_SPACING_INT // 2
|
|
214
|
+
cv2.rectangle(
|
|
215
|
+
frame_ndarray,
|
|
216
|
+
(left_x_int, LANE_MARK_TOP_Y_INT),
|
|
217
|
+
(
|
|
218
|
+
left_x_int + LANE_MARK_WIDTH_INT,
|
|
219
|
+
LANE_MARK_TOP_Y_INT + LANE_MARK_HEIGHT_INT,
|
|
220
|
+
),
|
|
221
|
+
LANE_COLOR_BGR,
|
|
222
|
+
thickness=-1,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _draw_vehicle(
|
|
227
|
+
frame_ndarray: ImageArray,
|
|
228
|
+
plate_box_obj: BoundingBox,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Draw the vehicle body, window, and readable licence plate."""
|
|
231
|
+
|
|
232
|
+
cv2.rectangle(
|
|
233
|
+
frame_ndarray,
|
|
234
|
+
(plate_box_obj.x1 - 62, plate_box_obj.y1 - 96),
|
|
235
|
+
(plate_box_obj.x2 + 62, plate_box_obj.y2 + 26),
|
|
236
|
+
VEHICLE_COLOR_BGR,
|
|
237
|
+
thickness=-1,
|
|
238
|
+
)
|
|
239
|
+
cv2.rectangle(
|
|
240
|
+
frame_ndarray,
|
|
241
|
+
(plate_box_obj.x1 - 54, plate_box_obj.y1 - 84),
|
|
242
|
+
(plate_box_obj.x2 + 54, plate_box_obj.y1 - 30),
|
|
243
|
+
WINDOW_COLOR_BGR,
|
|
244
|
+
thickness=-1,
|
|
245
|
+
)
|
|
246
|
+
cv2.rectangle(
|
|
247
|
+
frame_ndarray,
|
|
248
|
+
(plate_box_obj.x1, plate_box_obj.y1),
|
|
249
|
+
(plate_box_obj.x2, plate_box_obj.y2),
|
|
250
|
+
PLATE_COLOR_BGR,
|
|
251
|
+
thickness=-1,
|
|
252
|
+
)
|
|
253
|
+
for character_index_int in range(PLATE_CHARACTER_COUNT_INT):
|
|
254
|
+
character_x_int = plate_box_obj.x1 + 8 + character_index_int * 14
|
|
255
|
+
cv2.rectangle(
|
|
256
|
+
frame_ndarray,
|
|
257
|
+
(character_x_int, plate_box_obj.y1 + 9),
|
|
258
|
+
(character_x_int + 9, plate_box_obj.y2 - 9),
|
|
259
|
+
PLATE_CHARACTER_COLOR_BGR,
|
|
260
|
+
thickness=-1,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _plate_box(frame_number_int: int) -> BoundingBox:
|
|
265
|
+
"""Return the licence-plate box for one frame of the sample scene.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
frame_number_int: One-based frame number.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Plate box that drifts steadily across the clip.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
left_x_int = PLATE_START_X_INT + PLATE_STEP_X_INT * (frame_number_int - 1)
|
|
275
|
+
return BoundingBox(
|
|
276
|
+
left_x_int,
|
|
277
|
+
PLATE_TOP_Y_INT,
|
|
278
|
+
left_x_int + PLATE_WIDTH_INT,
|
|
279
|
+
PLATE_TOP_Y_INT + PLATE_HEIGHT_INT,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _blur_plate_region(
|
|
284
|
+
frame_ndarray: ImageArray,
|
|
285
|
+
frame_number_int: int,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Blur the licence-plate region of one frame in place.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
frame_ndarray: Frame modified in place.
|
|
291
|
+
frame_number_int: One-based frame number.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
plate_box_obj = _plate_box(frame_number_int)
|
|
295
|
+
top_int = plate_box_obj.y1 - PLATE_BLUR_PADDING_INT
|
|
296
|
+
bottom_int = plate_box_obj.y2 + PLATE_BLUR_PADDING_INT
|
|
297
|
+
left_int = plate_box_obj.x1 - PLATE_BLUR_PADDING_INT
|
|
298
|
+
right_int = plate_box_obj.x2 + PLATE_BLUR_PADDING_INT
|
|
299
|
+
frame_ndarray[top_int:bottom_int, left_int:right_int] = cv2.GaussianBlur(
|
|
300
|
+
frame_ndarray[top_int:bottom_int, left_int:right_int],
|
|
301
|
+
PLATE_BLUR_KERNEL_TUPLE,
|
|
302
|
+
0,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
__all__ = [
|
|
307
|
+
"UNPROCESSED_FRAMES_TUPLE",
|
|
308
|
+
"DemonstrationResult",
|
|
309
|
+
"run_demonstration",
|
|
310
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Expose stable region-detection interfaces for Visual Verifier."""
|
|
2
|
+
|
|
3
|
+
from visual_verifier.detection.filtering import (
|
|
4
|
+
RegionFilterDecision,
|
|
5
|
+
evaluate_region_filtering,
|
|
6
|
+
is_region_accepted,
|
|
7
|
+
rejection_reasons,
|
|
8
|
+
)
|
|
9
|
+
from visual_verifier.detection.region_extraction import (
|
|
10
|
+
RegionDetectionResult,
|
|
11
|
+
detect_regions,
|
|
12
|
+
)
|
|
13
|
+
from visual_verifier.detection.severity import (
|
|
14
|
+
SeverityLabel,
|
|
15
|
+
calculate_severity,
|
|
16
|
+
severity_label,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"RegionDetectionResult",
|
|
21
|
+
"RegionFilterDecision",
|
|
22
|
+
"SeverityLabel",
|
|
23
|
+
"calculate_severity",
|
|
24
|
+
"detect_regions",
|
|
25
|
+
"evaluate_region_filtering",
|
|
26
|
+
"is_region_accepted",
|
|
27
|
+
"rejection_reasons",
|
|
28
|
+
"severity_label",
|
|
29
|
+
]
|