homesec 0.1.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.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
homesec/models/events.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Event models for clip lifecycle tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Annotated, Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from homesec.models.filter import FilterResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClipEvent(BaseModel):
|
|
14
|
+
"""Base class for all clip lifecycle events."""
|
|
15
|
+
id: int | None = None
|
|
16
|
+
clip_id: str
|
|
17
|
+
timestamp: datetime
|
|
18
|
+
event_type: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClipRecordedEvent(ClipEvent):
|
|
22
|
+
"""Clip was recorded and queued for processing."""
|
|
23
|
+
|
|
24
|
+
event_type: Literal["clip_recorded"] = "clip_recorded"
|
|
25
|
+
camera_name: str
|
|
26
|
+
duration_s: float
|
|
27
|
+
source_type: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClipDeletedEvent(ClipEvent):
|
|
31
|
+
"""Clip was deleted by a maintenance workflow (e.g., cleanup CLI)."""
|
|
32
|
+
|
|
33
|
+
event_type: Literal["clip_deleted"] = "clip_deleted"
|
|
34
|
+
camera_name: str
|
|
35
|
+
reason: str
|
|
36
|
+
run_id: str
|
|
37
|
+
local_path: str
|
|
38
|
+
storage_uri: str | None
|
|
39
|
+
deleted_local: bool
|
|
40
|
+
deleted_storage: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ClipRecheckedEvent(ClipEvent):
|
|
44
|
+
"""Clip was re-analyzed by a maintenance workflow."""
|
|
45
|
+
|
|
46
|
+
event_type: Literal["clip_rechecked"] = "clip_rechecked"
|
|
47
|
+
camera_name: str
|
|
48
|
+
reason: str
|
|
49
|
+
run_id: str
|
|
50
|
+
prior_filter: FilterResult | None
|
|
51
|
+
recheck_filter: FilterResult
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UploadStartedEvent(ClipEvent):
|
|
55
|
+
"""Upload to storage backend started."""
|
|
56
|
+
event_type: Literal["upload_started"] = "upload_started"
|
|
57
|
+
dest_key: str
|
|
58
|
+
attempt: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UploadCompletedEvent(ClipEvent):
|
|
62
|
+
"""Upload to storage backend completed successfully."""
|
|
63
|
+
event_type: Literal["upload_completed"] = "upload_completed"
|
|
64
|
+
storage_uri: str
|
|
65
|
+
view_url: str | None
|
|
66
|
+
attempt: int
|
|
67
|
+
duration_ms: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UploadFailedEvent(ClipEvent):
|
|
71
|
+
"""Upload to storage backend failed."""
|
|
72
|
+
event_type: Literal["upload_failed"] = "upload_failed"
|
|
73
|
+
attempt: int
|
|
74
|
+
error_message: str
|
|
75
|
+
error_type: str
|
|
76
|
+
will_retry: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FilterStartedEvent(ClipEvent):
|
|
80
|
+
"""Object detection filter started."""
|
|
81
|
+
event_type: Literal["filter_started"] = "filter_started"
|
|
82
|
+
attempt: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FilterCompletedEvent(ClipEvent):
|
|
86
|
+
"""Object detection filter completed."""
|
|
87
|
+
event_type: Literal["filter_completed"] = "filter_completed"
|
|
88
|
+
detected_classes: list[str]
|
|
89
|
+
confidence: float
|
|
90
|
+
model: str
|
|
91
|
+
sampled_frames: int
|
|
92
|
+
attempt: int
|
|
93
|
+
duration_ms: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FilterFailedEvent(ClipEvent):
|
|
97
|
+
"""Object detection filter failed."""
|
|
98
|
+
event_type: Literal["filter_failed"] = "filter_failed"
|
|
99
|
+
attempt: int
|
|
100
|
+
error_message: str
|
|
101
|
+
error_type: str
|
|
102
|
+
will_retry: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class VLMStartedEvent(ClipEvent):
|
|
106
|
+
"""VLM analysis started."""
|
|
107
|
+
event_type: Literal["vlm_started"] = "vlm_started"
|
|
108
|
+
attempt: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class VLMCompletedEvent(ClipEvent):
|
|
112
|
+
"""VLM analysis completed."""
|
|
113
|
+
event_type: Literal["vlm_completed"] = "vlm_completed"
|
|
114
|
+
risk_level: str
|
|
115
|
+
activity_type: str
|
|
116
|
+
summary: str
|
|
117
|
+
analysis: dict[str, Any]
|
|
118
|
+
prompt_tokens: int | None
|
|
119
|
+
completion_tokens: int | None
|
|
120
|
+
attempt: int
|
|
121
|
+
duration_ms: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class VLMFailedEvent(ClipEvent):
|
|
125
|
+
"""VLM analysis failed."""
|
|
126
|
+
event_type: Literal["vlm_failed"] = "vlm_failed"
|
|
127
|
+
attempt: int
|
|
128
|
+
error_message: str
|
|
129
|
+
error_type: str
|
|
130
|
+
will_retry: bool
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class VLMSkippedEvent(ClipEvent):
|
|
134
|
+
"""VLM analysis skipped (no trigger classes detected)."""
|
|
135
|
+
event_type: Literal["vlm_skipped"] = "vlm_skipped"
|
|
136
|
+
reason: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AlertDecisionMadeEvent(ClipEvent):
|
|
140
|
+
"""Alert policy decision made."""
|
|
141
|
+
event_type: Literal["alert_decision_made"] = "alert_decision_made"
|
|
142
|
+
should_notify: bool
|
|
143
|
+
reason: str
|
|
144
|
+
detected_classes: list[str] | None
|
|
145
|
+
vlm_risk: str | None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class NotificationSentEvent(ClipEvent):
|
|
149
|
+
"""Notification sent successfully."""
|
|
150
|
+
event_type: Literal["notification_sent"] = "notification_sent"
|
|
151
|
+
notifier_name: str
|
|
152
|
+
dedupe_key: str
|
|
153
|
+
attempt: int = 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class NotificationFailedEvent(ClipEvent):
|
|
157
|
+
"""Notification send failed."""
|
|
158
|
+
event_type: Literal["notification_failed"] = "notification_failed"
|
|
159
|
+
notifier_name: str
|
|
160
|
+
error_message: str
|
|
161
|
+
error_type: str
|
|
162
|
+
attempt: int = 1
|
|
163
|
+
will_retry: bool = False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
ClipLifecycleEvent = Annotated[
|
|
167
|
+
ClipRecordedEvent
|
|
168
|
+
| ClipDeletedEvent
|
|
169
|
+
| ClipRecheckedEvent
|
|
170
|
+
| UploadStartedEvent
|
|
171
|
+
| UploadCompletedEvent
|
|
172
|
+
| UploadFailedEvent
|
|
173
|
+
| FilterStartedEvent
|
|
174
|
+
| FilterCompletedEvent
|
|
175
|
+
| FilterFailedEvent
|
|
176
|
+
| VLMStartedEvent
|
|
177
|
+
| VLMCompletedEvent
|
|
178
|
+
| VLMFailedEvent
|
|
179
|
+
| VLMSkippedEvent
|
|
180
|
+
| AlertDecisionMadeEvent
|
|
181
|
+
| NotificationSentEvent
|
|
182
|
+
| NotificationFailedEvent,
|
|
183
|
+
Field(discriminator="event_type"),
|
|
184
|
+
]
|
homesec/models/filter.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Object detection filter data and config models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilterResult(BaseModel):
|
|
11
|
+
"""Result from object detection filter on a video clip."""
|
|
12
|
+
|
|
13
|
+
detected_classes: list[str]
|
|
14
|
+
confidence: float
|
|
15
|
+
model: str
|
|
16
|
+
sampled_frames: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class YoloFilterSettings(BaseModel):
|
|
20
|
+
"""YOLO filter settings.
|
|
21
|
+
|
|
22
|
+
model_path accepts a filename; bare names resolve under ./yolo_cache.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = {"extra": "forbid"}
|
|
26
|
+
|
|
27
|
+
model_path: str = "yolo11n.pt"
|
|
28
|
+
classes: list[str] = Field(default_factory=lambda: ["person"], min_length=1)
|
|
29
|
+
min_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
30
|
+
sample_fps: int = Field(default=2, ge=1)
|
|
31
|
+
min_box_h_ratio: float = Field(default=0.1, ge=0.0, le=1.0)
|
|
32
|
+
min_hits: int = Field(default=1, ge=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FilterOverrides(BaseModel):
|
|
36
|
+
"""Runtime overrides for filter settings (model path not allowed)."""
|
|
37
|
+
|
|
38
|
+
model_config = {"extra": "forbid"}
|
|
39
|
+
|
|
40
|
+
classes: list[str] | None = Field(default=None, min_length=1)
|
|
41
|
+
min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
42
|
+
sample_fps: int | None = Field(default=None, ge=1)
|
|
43
|
+
min_box_h_ratio: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
44
|
+
min_hits: int | None = Field(default=None, ge=1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FilterConfig(BaseModel):
|
|
48
|
+
"""Base filter configuration (plugin-agnostic).
|
|
49
|
+
|
|
50
|
+
Plugin-specific config is stored in the 'config' field.
|
|
51
|
+
- During YAML parsing: dict[str, object] (preserves all third-party fields)
|
|
52
|
+
- After plugin discovery: BaseModel subclass (validated against plugin.config_model)
|
|
53
|
+
|
|
54
|
+
Note: Plugin names are validated against the registry at runtime via
|
|
55
|
+
validate_plugin_names(). This allows third-party plugins via entry points.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
model_config = {"extra": "forbid"}
|
|
59
|
+
|
|
60
|
+
plugin: str
|
|
61
|
+
max_workers: int = Field(default=4, ge=1)
|
|
62
|
+
config: dict[str, object] | BaseModel # Dict before validation, BaseModel after
|
homesec/models/source.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Source configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RTSPSourceConfig(BaseModel):
|
|
9
|
+
"""RTSP source configuration."""
|
|
10
|
+
|
|
11
|
+
model_config = {"extra": "forbid"}
|
|
12
|
+
|
|
13
|
+
rtsp_url_env: str | None = None
|
|
14
|
+
rtsp_url: str | None = None
|
|
15
|
+
detect_rtsp_url_env: str | None = None
|
|
16
|
+
detect_rtsp_url: str | None = None
|
|
17
|
+
output_dir: str = "./recordings"
|
|
18
|
+
pixel_threshold: int = 45
|
|
19
|
+
min_changed_pct: float = 1.0
|
|
20
|
+
blur_kernel: int = 5
|
|
21
|
+
stop_delay: float = 10.0
|
|
22
|
+
max_recording_s: float = 60.0
|
|
23
|
+
max_reconnect_attempts: int = 20
|
|
24
|
+
disable_hwaccel: bool = False
|
|
25
|
+
frame_timeout_s: float = 2.0
|
|
26
|
+
frame_queue_size: int = 20
|
|
27
|
+
reconnect_backoff_s: float = 1.0
|
|
28
|
+
debug_motion: bool = False
|
|
29
|
+
heartbeat_s: float = 30.0
|
|
30
|
+
rtsp_connect_timeout_s: float = 2.0
|
|
31
|
+
rtsp_io_timeout_s: float = 2.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalFolderSourceConfig(BaseModel):
|
|
35
|
+
"""Local folder source configuration."""
|
|
36
|
+
|
|
37
|
+
model_config = {"extra": "forbid"}
|
|
38
|
+
|
|
39
|
+
watch_dir: str = "recordings"
|
|
40
|
+
poll_interval: float = 1.0
|
|
41
|
+
stability_threshold_s: float = 3.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FtpSourceConfig(BaseModel):
|
|
45
|
+
"""FTP source configuration."""
|
|
46
|
+
|
|
47
|
+
model_config = {"extra": "forbid"}
|
|
48
|
+
|
|
49
|
+
host: str = "0.0.0.0"
|
|
50
|
+
port: int = 2121
|
|
51
|
+
root_dir: str = "./ftp_incoming"
|
|
52
|
+
ftp_subdir: str | None = None
|
|
53
|
+
anonymous: bool = True
|
|
54
|
+
username_env: str | None = None
|
|
55
|
+
password_env: str | None = None
|
|
56
|
+
perms: str = "elw"
|
|
57
|
+
passive_ports: str | None = None
|
|
58
|
+
masquerade_address: str | None = None
|
|
59
|
+
heartbeat_s: float = 30.0
|
|
60
|
+
allowed_extensions: list[str] = Field(default_factory=lambda: [".mp4"])
|
|
61
|
+
delete_non_matching: bool = True
|
|
62
|
+
delete_incomplete: bool = True
|
|
63
|
+
default_duration_s: float = 10.0
|
|
64
|
+
log_level: str = "INFO"
|
|
65
|
+
|
|
66
|
+
@field_validator("allowed_extensions")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _normalize_extensions(cls, value: list[str]) -> list[str]:
|
|
69
|
+
cleaned: list[str] = []
|
|
70
|
+
for item in value:
|
|
71
|
+
ext = str(item).strip().lower()
|
|
72
|
+
if not ext:
|
|
73
|
+
continue
|
|
74
|
+
if not ext.startswith("."):
|
|
75
|
+
ext = f".{ext}"
|
|
76
|
+
cleaned.append(ext)
|
|
77
|
+
return cleaned
|
homesec/models/vlm.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""VLM analysis data and config models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RiskLevel = Literal["low", "medium", "high", "critical"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnalysisResult(BaseModel):
|
|
14
|
+
"""Structured result from VLM analysis of a video clip."""
|
|
15
|
+
|
|
16
|
+
risk_level: RiskLevel
|
|
17
|
+
activity_type: str
|
|
18
|
+
summary: str
|
|
19
|
+
analysis: SequenceAnalysis | None = None
|
|
20
|
+
prompt_tokens: int | None = None
|
|
21
|
+
completion_tokens: int | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EntityTimeline(BaseModel):
|
|
25
|
+
"""Timeline of an entity across multiple frames."""
|
|
26
|
+
|
|
27
|
+
model_config = {"extra": "forbid"}
|
|
28
|
+
type: Literal["person", "vehicle", "animal", "package", "object", "unknown"]
|
|
29
|
+
first_seen_timestamp: str
|
|
30
|
+
last_seen_timestamp: str
|
|
31
|
+
description: str
|
|
32
|
+
movement: str
|
|
33
|
+
location: str
|
|
34
|
+
interaction: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SequenceAnalysis(BaseModel):
|
|
38
|
+
"""Structured analysis of a sequence of security camera frames."""
|
|
39
|
+
|
|
40
|
+
model_config = {"extra": "forbid"}
|
|
41
|
+
sequence_description: str
|
|
42
|
+
max_risk_level: RiskLevel
|
|
43
|
+
primary_activity: Literal[
|
|
44
|
+
"normal_delivery",
|
|
45
|
+
"normal_visitor",
|
|
46
|
+
"passerby",
|
|
47
|
+
"suspicious_behavior",
|
|
48
|
+
"dangerous_activity",
|
|
49
|
+
"no_activity",
|
|
50
|
+
"unknown",
|
|
51
|
+
]
|
|
52
|
+
observations: list[str]
|
|
53
|
+
entities_timeline: list[EntityTimeline]
|
|
54
|
+
requires_review: bool
|
|
55
|
+
frame_count: int
|
|
56
|
+
video_start_time: str
|
|
57
|
+
video_end_time: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class VLMPreprocessConfig(BaseModel):
|
|
61
|
+
"""Preprocessing configuration for VLM frame extraction."""
|
|
62
|
+
|
|
63
|
+
model_config = {"extra": "forbid"}
|
|
64
|
+
max_frames: int = 10
|
|
65
|
+
max_size: int = 1024
|
|
66
|
+
quality: int = 85
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenAILLMConfig(BaseModel):
|
|
70
|
+
"""OpenAI-compatible LLM configuration."""
|
|
71
|
+
|
|
72
|
+
model_config = {"extra": "forbid"}
|
|
73
|
+
api_key_env: str
|
|
74
|
+
model: str
|
|
75
|
+
base_url: str = "https://api.openai.com/v1"
|
|
76
|
+
token_param: Literal["max_tokens", "max_completion_tokens"] = "max_completion_tokens"
|
|
77
|
+
max_completion_tokens: int = 10_000
|
|
78
|
+
max_tokens: int | None = None
|
|
79
|
+
temperature: float | None = 0.0
|
|
80
|
+
request_timeout: float = 60.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class VLMConfig(BaseModel):
|
|
84
|
+
"""Base VLM configuration.
|
|
85
|
+
|
|
86
|
+
LLM-specific config is stored in the 'llm' field.
|
|
87
|
+
- During YAML parsing: dict[str, object] (preserves all third-party fields)
|
|
88
|
+
- After plugin discovery: BaseModel subclass (validated against plugin.config_model)
|
|
89
|
+
|
|
90
|
+
Note: Backend names are validated against the registry at runtime via
|
|
91
|
+
validate_plugin_names(). This allows third-party VLM plugins via entry points.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
model_config = {"extra": "forbid"}
|
|
95
|
+
backend: str
|
|
96
|
+
trigger_classes: list[str] = Field(default_factory=lambda: ["person"])
|
|
97
|
+
max_workers: int = Field(default=2, ge=1)
|
|
98
|
+
llm: dict[str, object] | BaseModel # Dict before validation, BaseModel after
|
|
99
|
+
preprocessing: VLMPreprocessConfig = Field(default_factory=VLMPreprocessConfig)
|