|
12 | 12 | TileLayout = Literal["auto", "2x2", "1x4", "4x1"] |
13 | 13 | Precision = Literal["FP32", "FP16"] |
14 | 14 | ModelType = Literal["pytorch", "tensorflow"] |
| 15 | +TriggerRole = Literal["off", "external", "master", "follower"] |
| 16 | +TriggerActivation = Literal["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"] |
15 | 17 |
|
16 | 18 |
|
17 | 19 | class CameraSettings(BaseModel): |
@@ -168,6 +170,137 @@ def check_diff(old: CameraSettings, new: CameraSettings) -> dict: |
168 | 170 | pass |
169 | 171 | return out |
170 | 172 |
|
| 173 | + def backend_options(self, backend: str | None = None) -> dict[str, Any]: |
| 174 | + key = backend or self.backend |
| 175 | + props = self.properties if isinstance(self.properties, dict) else {} |
| 176 | + ns = props.get(str(key).lower(), {}) |
| 177 | + return ns if isinstance(ns, dict) else {} |
| 178 | + |
| 179 | + def get_trigger_settings(self, backend: str | None = None) -> CameraTriggerSettings: |
| 180 | + ns = self.backend_options(backend) |
| 181 | + return CameraTriggerSettings.from_any(ns.get("trigger")) |
| 182 | + |
| 183 | + def set_trigger_settings(self, trigger: CameraTriggerSettings, backend: str | None = None) -> None: |
| 184 | + key = backend or self.backend |
| 185 | + if not isinstance(self.properties, dict): |
| 186 | + self.properties = {} |
| 187 | + ns = self.properties.setdefault(str(key).lower(), {}) |
| 188 | + if not isinstance(ns, dict): |
| 189 | + ns = {} |
| 190 | + self.properties[str(key).lower()] = ns |
| 191 | + ns["trigger"] = trigger.to_properties() |
| 192 | + |
| 193 | + def with_save_defaults(self) -> CameraSettings: |
| 194 | + out = self.model_copy(deep=True) |
| 195 | + |
| 196 | + backend = (out.backend or "").lower() |
| 197 | + if backend != "gentl": |
| 198 | + return out |
| 199 | + |
| 200 | + if not isinstance(out.properties, dict): |
| 201 | + out.properties = {} |
| 202 | + |
| 203 | + ns = out.properties.setdefault("gentl", {}) |
| 204 | + if not isinstance(ns, dict): |
| 205 | + ns = {} |
| 206 | + out.properties["gentl"] = ns |
| 207 | + |
| 208 | + ns.setdefault("trigger", CameraTriggerSettings().to_properties()) |
| 209 | + |
| 210 | + return out |
| 211 | + |
| 212 | + |
| 213 | +class CameraTriggerSettings(BaseModel): |
| 214 | + """ |
| 215 | + Generic hardware-trigger settings. |
| 216 | +
|
| 217 | + Backend-specific code may ignore fields that are unsupported by a given |
| 218 | + camera/SDK. For GenTL, these map to common GenICam nodes such as: |
| 219 | + TriggerMode, TriggerSelector, TriggerSource, TriggerActivation, |
| 220 | + LineSelector, LineMode, and LineSource. |
| 221 | + """ |
| 222 | + |
| 223 | + role: TriggerRole = "off" |
| 224 | + |
| 225 | + # Input trigger config: external/follower |
| 226 | + selector: str = "FrameStart" |
| 227 | + source: str = "auto" |
| 228 | + activation: TriggerActivation | str = "RisingEdge" |
| 229 | + |
| 230 | + # Output config: master |
| 231 | + output_line: str = "Line2" |
| 232 | + output_source: str = "ExposureActive" |
| 233 | + |
| 234 | + # Runtime behavior |
| 235 | + timeout: float | None = None |
| 236 | + strict: bool = False |
| 237 | + |
| 238 | + @field_validator("role", mode="before") |
| 239 | + @classmethod |
| 240 | + def _coerce_role(cls, v): |
| 241 | + if v is None: |
| 242 | + return "off" |
| 243 | + |
| 244 | + s = str(v).strip().lower() |
| 245 | + aliases = { |
| 246 | + "": "off", |
| 247 | + "none": "off", |
| 248 | + "false": "off", |
| 249 | + "disabled": "off", |
| 250 | + "disable": "off", |
| 251 | + "off": "off", |
| 252 | + "true": "external", |
| 253 | + "on": "external", |
| 254 | + "trigger": "external", |
| 255 | + "triggered": "external", |
| 256 | + "external": "external", |
| 257 | + "follower": "follower", |
| 258 | + "slave": "follower", |
| 259 | + "master": "master", |
| 260 | + "main": "master", |
| 261 | + } |
| 262 | + return aliases.get(s, s) |
| 263 | + |
| 264 | + @field_validator("timeout", mode="before") |
| 265 | + @classmethod |
| 266 | + def _coerce_timeout(cls, v): |
| 267 | + if v in (None, ""): |
| 268 | + return None |
| 269 | + try: |
| 270 | + fv = float(v) |
| 271 | + except Exception: |
| 272 | + return None |
| 273 | + return fv if fv > 0 else None |
| 274 | + |
| 275 | + @field_validator("source", mode="before") |
| 276 | + @classmethod |
| 277 | + def _coerce_source(cls, v): |
| 278 | + if v is None: |
| 279 | + return "auto" |
| 280 | + |
| 281 | + s = str(v).strip() |
| 282 | + if not s: |
| 283 | + return "auto" |
| 284 | + |
| 285 | + aliases = { |
| 286 | + "default": "auto", |
| 287 | + "automatic": "auto", |
| 288 | + "device": "auto", |
| 289 | + "camera": "auto", |
| 290 | + } |
| 291 | + return aliases.get(s.lower(), s) |
| 292 | + |
| 293 | + @classmethod |
| 294 | + def from_any(cls, value) -> CameraTriggerSettings: |
| 295 | + if isinstance(value, cls): |
| 296 | + return value |
| 297 | + if isinstance(value, dict): |
| 298 | + return cls(**value) |
| 299 | + return cls() |
| 300 | + |
| 301 | + def to_properties(self) -> dict[str, Any]: |
| 302 | + return self.model_dump(exclude_none=True) |
| 303 | + |
171 | 304 |
|
172 | 305 | class MultiCameraSettings(BaseModel): |
173 | 306 | cameras: list[CameraSettings] = Field(default_factory=list) |
@@ -206,12 +339,19 @@ def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: |
206 | 339 | return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) |
207 | 340 |
|
208 | 341 | def to_dict(self) -> dict[str, Any]: |
| 342 | + out = self.with_save_defaults() |
209 | 343 | return { |
210 | | - "cameras": [cam.model_dump() for cam in self.cameras], |
211 | | - "max_cameras": self.max_cameras, |
212 | | - "tile_layout": self.tile_layout, |
| 344 | + "cameras": [cam.model_dump() for cam in out.cameras], |
| 345 | + "max_cameras": out.max_cameras, |
| 346 | + "tile_layout": out.tile_layout, |
213 | 347 | } |
214 | 348 |
|
| 349 | + def with_save_defaults(self) -> MultiCameraSettings: |
| 350 | + """Return a copy with save defaults applied to all cameras.""" |
| 351 | + out = self.model_copy(deep=True) |
| 352 | + out.cameras = [cam.with_save_defaults() for cam in out.cameras] |
| 353 | + return out |
| 354 | + |
215 | 355 |
|
216 | 356 | class DynamicCropModel(BaseModel): |
217 | 357 | enabled: bool = False |
@@ -377,10 +517,13 @@ def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: |
377 | 517 | ) |
378 | 518 |
|
379 | 519 | def to_dict(self) -> dict[str, Any]: |
| 520 | + camera = self.camera.with_save_defaults() |
| 521 | + multi_camera = self.multi_camera.with_save_defaults() |
| 522 | + |
380 | 523 | return { |
381 | 524 | "version": self.version, |
382 | | - "camera": self.camera.model_dump(), |
383 | | - "multi_camera": self.multi_camera.to_dict(), |
| 525 | + "camera": camera.model_dump(), |
| 526 | + "multi_camera": multi_camera.to_dict(), |
384 | 527 | "dlc": self.dlc.model_dump(), |
385 | 528 | "recording": self.recording.model_dump(), |
386 | 529 | "bbox": self.bbox.model_dump(), |
|
0 commit comments