492 lines
21 KiB
Python
492 lines
21 KiB
Python
from __future__ import annotations
|
||
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
|
||
def _fish_api_env_file() -> Path:
|
||
"""fish_api/.env — 与启动 cwd 无关,避免从仓库根跑 uvicorn 时读不到 .env。"""
|
||
return Path(__file__).resolve().parents[1] / ".env"
|
||
|
||
|
||
def fish_repo_root() -> Path:
|
||
# fish_api/app/settings.py -> parent[2] = repo root (contains FishMeasure/, fish_api/)
|
||
return Path(__file__).resolve().parents[2]
|
||
|
||
|
||
def models_dir() -> Path:
|
||
"""仓库内统一权重目录(YOLO / DGCNN / PointNet / X3D / SAM 等),与 FishMeasure 代码目录解耦。"""
|
||
return fish_repo_root() / "models"
|
||
|
||
|
||
def _default_stream_tmp() -> Path:
|
||
return fish_repo_root() / "fish_api" / ".data" / "ingest"
|
||
|
||
|
||
def _default_media_root() -> Path:
|
||
return fish_repo_root() / "fish_api" / ".data" / "media"
|
||
|
||
|
||
def _default_sqlite_path() -> Path:
|
||
return fish_repo_root() / "fish_api" / ".data" / "app.db"
|
||
|
||
|
||
def _default_action_output_root() -> Path:
|
||
return fish_repo_root() / "fish_api" / ".data" / "action_output"
|
||
|
||
|
||
def _default_measure_debug_log_dir() -> Path:
|
||
"""DGCNN 体重推算过程等可调试文本(与终端一致的 calculation log)。"""
|
||
return fish_repo_root() / "fish_api" / ".data" / "logs" / "measure"
|
||
|
||
|
||
def _default_log_dir() -> Path:
|
||
"""fish_api 主日志目录(runtime_*.log / events_*.jsonl)。"""
|
||
return fish_repo_root() / "fish_api" / ".data" / "logs" / "fish_api"
|
||
|
||
|
||
def _default_log_subprocess_dir() -> Path:
|
||
"""子进程整段输出根目录;measure/action 子目录在写入时拼上。"""
|
||
return fish_repo_root() / "fish_api" / ".data" / "logs"
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(
|
||
env_file=_fish_api_env_file(),
|
||
env_file_encoding="utf-8",
|
||
extra="ignore",
|
||
)
|
||
|
||
#: 对外可访问的 API 基址(无末尾 `/`),用于 biomass 等 JSON 里 `video_left` / `video_right` 的绝对 URL。环境变量:**PUBLIC_BASE_URL**
|
||
public_base_url: str = Field(
|
||
default="http://127.0.0.1:8000",
|
||
validation_alias=AliasChoices("PUBLIC_BASE_URL", "public_base_url"),
|
||
)
|
||
#: ZED 录制 CLI(``fish-zed-record``)等访问本机 API 时的基址;未设时与 ``public_base_url`` 相同。**FISH_API_BASE_URL**
|
||
fish_api_base_url: str = Field(
|
||
default="",
|
||
validation_alias=AliasChoices("FISH_API_BASE_URL", "fish_api_base_url"),
|
||
)
|
||
|
||
ingest_api_key: str = ""
|
||
|
||
stream_tmp_dir: Path = Field(default_factory=_default_stream_tmp)
|
||
media_root: Path = Field(default_factory=_default_media_root)
|
||
sqlite_path: Path = Field(default_factory=_default_sqlite_path)
|
||
|
||
fish_measure_root: Path = fish_repo_root() / "FishMeasure"
|
||
fish_action_root: Path = fish_repo_root() / "FishAction"
|
||
|
||
#: FishMeasure 推理输出(与 SQLite、媒体缓存同属 fish_api/.data;启动脚本默认保留,设置 CLEAR_MEASURE_OUTPUT=1 可清空)
|
||
measure_output_root: Path = fish_repo_root() / "fish_api" / ".data" / "measure_output"
|
||
#: 体重推算过程等调试文本写入目录(默认 fish_api/.data/logs/measure)。**MEASURE_DEBUG_LOG_DIR**
|
||
measure_debug_log_dir: Path = Field(
|
||
default_factory=_default_measure_debug_log_dir,
|
||
validation_alias=AliasChoices("MEASURE_DEBUG_LOG_DIR", "measure_debug_log_dir"),
|
||
)
|
||
#: 为 False 时不写入上述目录(仍打 logger)。**MEASURE_DEBUG_LOG_WRITE**
|
||
measure_debug_log_write: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices("MEASURE_DEBUG_LOG_WRITE", "measure_debug_log_write"),
|
||
)
|
||
#: FishAction 侧预留目录(与 measure 对称;启动脚本默认保留,设置 CLEAR_ACTION_OUTPUT=1 可清空)
|
||
action_output_root: Path = Field(default_factory=_default_action_output_root)
|
||
|
||
python_fish_measure: str = ""
|
||
python_fish_action: str = ""
|
||
|
||
#: ffmpeg 可执行文件路径。非空则优先用该文件;为空时依次尝试 ``/usr/bin/ffmpeg``、``/usr/local/bin/ffmpeg``,否则用 PATH 中的 ``ffmpeg``。(若要用仓库 ``tools/ffmpeg``,请设为本机绝对路径。)**FFMPEG_PATH**
|
||
ffmpeg_path: str = ""
|
||
|
||
#: SAM/CUDA 设备(cuda 或 cpu)
|
||
sam_device: str = "cuda"
|
||
|
||
#: 为 True 时在视频右上角显示大型 weight/length 标签(10倍字体)
|
||
predict_show_large_labels_at_top_right: bool = False
|
||
|
||
#: FishMeasure 中 YOLO 置信度见 ``measure_yolo_conf`` / ``MEASURE_YOLO_CONF``;其余脚本内参数仍可在 FishMeasure 目录修改。
|
||
#: FishAction 核心参数见 ``action_checkpoint`` 等。
|
||
|
||
#: FishAction X3D 模型路径(不设则用 models/action_x3d/checkpoint_best.pt)
|
||
action_checkpoint: Optional[str] = None
|
||
|
||
#: 为 True 时复用已有 cloud/*.ply(传 --reuse-existing-clouds)
|
||
#: 为 False 时强制重新生成点云(传 --no-reuse-existing-clouds)
|
||
measure_reuse_existing_clouds: bool = True
|
||
|
||
#: YOLO 检测置信度,传给 ``predict_weigth_from_svo2.py --conf``(与 FishMeasure ``run_predict_from_svo2_fish9.sh`` 等使用的 0.8 对齐)。**MEASURE_YOLO_CONF**
|
||
measure_yolo_conf: float = Field(
|
||
default=0.8,
|
||
ge=0.0,
|
||
le=1.0,
|
||
validation_alias=AliasChoices("MEASURE_YOLO_CONF", "measure_yolo_conf"),
|
||
)
|
||
#: 传给 FishMeasure ``--filter-pointcloud``(默认开启,与 fish9 脚本对齐)。
|
||
measure_filter_pointcloud: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_FILTER_POINTCLOUD", "measure_filter_pointcloud"
|
||
),
|
||
)
|
||
#: 传给 FishMeasure ``--use-density-filter``(默认开启,与 fish9 脚本对齐)。
|
||
measure_use_density_filter: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_USE_DENSITY_FILTER", "measure_use_density_filter"
|
||
),
|
||
)
|
||
#: 传给 FishMeasure ``--use-pointcloud-classifier``(默认开启,与 fish9 脚本对齐)。
|
||
measure_use_pointcloud_classifier: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_USE_POINTCLOUD_CLASSIFIER", "measure_use_pointcloud_classifier"
|
||
),
|
||
)
|
||
#: PointNet2 点云分类器阈值,传给 ``--pointcloud-classifier-threshold``。
|
||
measure_pointcloud_classifier_threshold: float = Field(
|
||
default=0.7,
|
||
ge=0.0,
|
||
le=1.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_POINTCLOUD_CLASSIFIER_THRESHOLD",
|
||
"measure_pointcloud_classifier_threshold",
|
||
),
|
||
)
|
||
#: 点云分类器模型路径,传给 ``--pointcloud-classifier``。
|
||
measure_pointcloud_classifier: Optional[Path] = Field(
|
||
default=None,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_POINTCLOUD_CLASSIFIER", "measure_pointcloud_classifier"
|
||
),
|
||
)
|
||
#: 传给 FishMeasure ``--use-flatness-filter``(默认开启,与 fish9 脚本对齐)。
|
||
measure_use_flatness_filter: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_USE_FLATNESS_FILTER", "measure_use_flatness_filter"
|
||
),
|
||
)
|
||
#: 平整度阈值,传给 ``--flatness-threshold``。
|
||
measure_flatness_threshold: float = Field(
|
||
default=55.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_FLATNESS_THRESHOLD", "measure_flatness_threshold"
|
||
),
|
||
)
|
||
|
||
# ── 体重聚合规则(传给 predict_weigth_from_svo2.py → test_dgcnn_weight_estimator.py) ──
|
||
|
||
#: DGCNN top-K 帧数,传给 ``--weight-top-k``。**MEASURE_WEIGHT_TOP_K**
|
||
measure_weight_top_k: int = Field(
|
||
default=5,
|
||
ge=1,
|
||
validation_alias=AliasChoices("MEASURE_WEIGHT_TOP_K", "measure_weight_top_k"),
|
||
)
|
||
#: 按长度选 top-K,传给 ``--weight-top-by-length``。**MEASURE_WEIGHT_TOP_BY_LENGTH**
|
||
measure_weight_top_by_length: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_TOP_BY_LENGTH", "measure_weight_top_by_length"
|
||
),
|
||
)
|
||
#: top-K 按长度选时,若 K 个平均长度 > 此值则切为按重量选,传给 ``--weight-length-switch-mm``。**MEASURE_WEIGHT_LENGTH_SWITCH_MM**
|
||
measure_weight_length_switch_mm: float = Field(
|
||
default=319.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_LENGTH_SWITCH_MM", "measure_weight_length_switch_mm"
|
||
),
|
||
)
|
||
#: 几何过滤:length > 此值的帧排除,传给 ``--weight-max-length-mm``(0 关闭)。**MEASURE_WEIGHT_MAX_LENGTH_MM**
|
||
measure_weight_max_length_mm: float = Field(
|
||
default=400.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_MAX_LENGTH_MM", "measure_weight_max_length_mm"
|
||
),
|
||
)
|
||
#: 几何过滤:PCA 长/宽 < 此值的帧排除,传给 ``--weight-min-length-width-ratio``(0 关闭)。**MEASURE_WEIGHT_MIN_LENGTH_WIDTH_RATIO**
|
||
measure_weight_min_length_width_ratio: float = Field(
|
||
default=1.5,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_MIN_LENGTH_WIDTH_RATIO", "measure_weight_min_length_width_ratio"
|
||
),
|
||
)
|
||
#: 全池均值模式,传给 ``--weight-average-all-after-filter``。**MEASURE_WEIGHT_AVERAGE_ALL_AFTER_FILTER**
|
||
measure_weight_average_all_after_filter: bool = Field(
|
||
default=False,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_AVERAGE_ALL_AFTER_FILTER", "measure_weight_average_all_after_filter"
|
||
),
|
||
)
|
||
#: 全池均值 > 此值时改用 max(规则 A),传给 ``--weight-average-all-fallback-max-if-mean-over-g``(0 关闭)。**MEASURE_WEIGHT_AVG_ALL_FALLBACK_MAX_G**
|
||
measure_weight_avg_all_fallback_max_g: float = Field(
|
||
default=400.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_AVG_ALL_FALLBACK_MAX_G", "measure_weight_avg_all_fallback_max_g"
|
||
),
|
||
)
|
||
#: 全池 candidates 均值 > 此值时改用 max(规则 B, 440g 保护),传给 ``--weight-mean-pool-fallback-max-if-over-g``(0 关闭)。**MEASURE_WEIGHT_MEAN_POOL_FALLBACK_MAX_G**
|
||
measure_weight_mean_pool_fallback_max_g: float = Field(
|
||
default=440.0,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_MEAN_POOL_FALLBACK_MAX_G", "measure_weight_mean_pool_fallback_max_g"
|
||
),
|
||
)
|
||
#: 异常值剔除开关,传给 ``--weight-remove-outliers``。**MEASURE_WEIGHT_REMOVE_OUTLIERS**
|
||
measure_weight_remove_outliers: bool = Field(
|
||
default=False,
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_REMOVE_OUTLIERS", "measure_weight_remove_outliers"
|
||
),
|
||
)
|
||
#: 异常值剔除方法(iqr / zscore),传给 ``--weight-outlier-method``。**MEASURE_WEIGHT_OUTLIER_METHOD**
|
||
measure_weight_outlier_method: str = Field(
|
||
default="iqr",
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_WEIGHT_OUTLIER_METHOD", "measure_weight_outlier_method"
|
||
),
|
||
)
|
||
|
||
#: 非空时由 fish_api 在后台持续扫描该目录中的新 MP4 并跑 FishAction(与 ingest 共用 SQLite 最新结果)
|
||
action_watch_dir: Optional[Path] = None
|
||
action_watch_poll_interval: float = Field(default=2.0, ge=0.1)
|
||
action_watch_stable_polls: int = Field(default=3, ge=1)
|
||
action_watch_recursive: bool = False
|
||
#: 状态管理:true=持久化到 SQLite(重启后记住),false=内存模式(重启后清空)
|
||
action_watch_use_state_file: bool = True
|
||
|
||
#: 优先作为「水上视频」源文件;未设置时在 ACTION_WATCH_DIR 取最新 .mp4(FishAction 输入)。**BIOMASS_WATER_VIDEO_SOURCE**
|
||
biomass_water_video_source: Optional[Path] = None
|
||
#: 发布到 MEDIA_ROOT 的 H.264 文件名。**BIOMASS_WATER_VIDEO_MEDIA_NAME**
|
||
biomass_water_video_media_name: str = "biomass_water_surface.mp4"
|
||
|
||
#: 优先作为「声呐视频」源文件;未设置时在 BIOMASS_SONAR_VIDEO_DIR 取最新 .mp4。**BIOMASS_SONAR_VIDEO_SOURCE**
|
||
biomass_sonar_video_source: Optional[Path] = None
|
||
#: 声呐 MP4 目录(与 ACTION_WATCH_DIR 独立,避免与水面视频混用)。须**绝对路径**(例如 ``/home/you/shared``);``/shared`` 与 ``~/shared`` 不是同一目录。**BIOMASS_SONAR_VIDEO_DIR**
|
||
biomass_sonar_video_dir: Optional[Path] = None
|
||
#: 是否在 SONAR_VIDEO_DIR 中递归查找 .mp4。**BIOMASS_SONAR_VIDEO_RECURSIVE**
|
||
biomass_sonar_video_recursive: bool = False
|
||
#: 后台轮询间隔(秒):扫描 BIOMASS_SONAR_VIDEO_DIR 并处理已录完的 MP4。**BIOMASS_SONAR_VIDEO_POLL_INTERVAL**
|
||
biomass_sonar_video_poll_interval: float = Field(
|
||
default=5.0,
|
||
ge=1.0,
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_VIDEO_POLL_INTERVAL", "biomass_sonar_video_poll_interval"
|
||
),
|
||
)
|
||
#: ffmpeg ``-sseof`` 取源码最后 N 秒再送光流/转码(避免处理整段长录像)。**BIOMASS_SONAR_VIDEO_SLICE_SEC**
|
||
biomass_sonar_video_slice_sec: float = Field(
|
||
default=60.0,
|
||
ge=1.0,
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_VIDEO_SLICE_SEC", "biomass_sonar_video_slice_sec"
|
||
),
|
||
)
|
||
#: 声呐切片顺序:``tail`` = 每次取文件末尾 N 秒(``-sseof``);``sequential`` = 从 t=0 起按 N 秒顺序切完整块(不足一块不发布)。**BIOMASS_SONAR_SLICE_ORDER**
|
||
biomass_sonar_slice_order: str = Field(
|
||
default="sequential",
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_SLICE_ORDER", "biomass_sonar_slice_order"
|
||
),
|
||
)
|
||
#: 顺序模式下每轮监控循环最多发布几块完整切片;``0`` = 不限制(长视频可能长时间占住循环)。**BIOMASS_SONAR_MAX_CHUNKS_PER_POLL**
|
||
biomass_sonar_max_chunks_per_poll: int = Field(
|
||
default=1,
|
||
ge=0,
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_MAX_CHUNKS_PER_POLL",
|
||
"biomass_sonar_max_chunks_per_poll",
|
||
),
|
||
)
|
||
#: 发布到 MEDIA_ROOT 的 H.264 文件名。**BIOMASS_SONAR_VIDEO_MEDIA_NAME**
|
||
biomass_sonar_video_media_name: str = "biomass_sonar.mp4"
|
||
#: 为 True 时声呐发布管线在 ffmpeg 转 H.264 之前先做 Farneback 光流 overlay(与 ``/sonar/video/`` 返回的仍是同一 ``video_path`` 字段)。**BIOMASS_SONAR_OPTICAL_FLOW**
|
||
biomass_sonar_optical_flow: bool = Field(
|
||
default=True,
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_OPTICAL_FLOW", "biomass_sonar_optical_flow"
|
||
),
|
||
)
|
||
#: 光流处理前缩放帧(例如 0.5 减轻 Jetson 负载)。**BIOMASS_SONAR_OPTICAL_FLOW_RESIZE**
|
||
biomass_sonar_optical_flow_resize: float = Field(
|
||
default=1.0,
|
||
gt=0,
|
||
validation_alias=AliasChoices(
|
||
"BIOMASS_SONAR_OPTICAL_FLOW_RESIZE", "biomass_sonar_optical_flow_resize"
|
||
),
|
||
)
|
||
|
||
#: 非空时后台持续扫描该目录中的新 .svo2 并跑 FishMeasure(与 ingest 共用 SQLite 最新结果)
|
||
measure_watch_dir: Optional[Path] = None
|
||
measure_watch_poll_interval: float = Field(default=2.0, ge=0.1)
|
||
measure_watch_stable_polls: int = Field(default=3, ge=1)
|
||
measure_watch_recursive: bool = False
|
||
#: 状态管理:true=持久化到 SQLite(重启后记住),false=内存模式(重启后清空)
|
||
measure_watch_use_state_file: bool = True
|
||
#: 齐套后对各段 former 体重/体长聚合方式:``median``、``mean``、``trimmed_mean``(至少 3 段时去头尾再均值)。**MEASURE_FINAL_AGGREGATE_MODE**
|
||
measure_final_aggregate_mode: str = Field(
|
||
default="median",
|
||
validation_alias=AliasChoices(
|
||
"MEASURE_FINAL_AGGREGATE_MODE", "measure_final_aggregate_mode"
|
||
),
|
||
)
|
||
|
||
#: 分段 SVO2 输出目录;未设时:有 MEASURE_WATCH_DIR 则为 ``{MEASURE_WATCH_DIR}/fish{N}``(N 见 zed_svo_record_fish_id),否则为 ``{STREAM_TMP_DIR}/zed_svo2``。**ZED_SVO_RECORD_DIR**
|
||
zed_svo_record_dir: Optional[Path] = Field(
|
||
default=None,
|
||
validation_alias=AliasChoices("ZED_SVO_RECORD_DIR", "zed_svo_record_dir"),
|
||
)
|
||
#: 每段时长(秒),默认 30。**ZED_SVO_SEGMENT_SEC**
|
||
zed_svo_segment_sec: float = Field(
|
||
default=30.0,
|
||
ge=1.0,
|
||
validation_alias=AliasChoices("ZED_SVO_SEGMENT_SEC", "zed_svo_segment_sec"),
|
||
)
|
||
#: 仅连接一台相机时可选;指定序列号打开对应设备。**ZED_SERIAL_NUMBER**
|
||
zed_serial_number: Optional[int] = Field(
|
||
default=None,
|
||
validation_alias=AliasChoices("ZED_SERIAL_NUMBER", "zed_serial_number"),
|
||
)
|
||
#: 与 MEASURE_WATCH_DIR 组合为 ``fish{N}``(默认 1)。**ZED_SVO_RECORD_FISH_ID**
|
||
zed_svo_record_fish_id: int = Field(
|
||
default=1,
|
||
ge=1,
|
||
validation_alias=AliasChoices(
|
||
"ZED_SVO_RECORD_FISH_ID", "zed_svo_record_fish_id"
|
||
),
|
||
)
|
||
|
||
default_fish_species: str = "大黄鱼"
|
||
|
||
# ── 日志持久化(runtime + events JSONL + 子进程整段输出)──
|
||
|
||
#: 主日志目录(runtime_*.log / events_*.jsonl)。**LOG_DIR**
|
||
log_dir: Path = Field(
|
||
default_factory=_default_log_dir,
|
||
validation_alias=AliasChoices("LOG_DIR", "log_dir"),
|
||
)
|
||
#: 控制台日志级别。**LOG_LEVEL**
|
||
log_level: str = Field(
|
||
default="INFO",
|
||
validation_alias=AliasChoices("LOG_LEVEL", "log_level"),
|
||
)
|
||
#: 文件 sink 日志级别(默认与 ``log_level`` 一致)。**LOG_FILE_LEVEL**
|
||
log_file_level: str = Field(
|
||
default="INFO",
|
||
validation_alias=AliasChoices("LOG_FILE_LEVEL", "log_file_level"),
|
||
)
|
||
#: loguru rotation 表达式;``"00:00"`` 表示按本地日期滚动。**LOG_ROTATION**
|
||
log_rotation: str = Field(
|
||
default="00:00",
|
||
validation_alias=AliasChoices("LOG_ROTATION", "log_rotation"),
|
||
)
|
||
#: 文件保留天数。**LOG_RETENTION_DAYS**
|
||
log_retention_days: int = Field(
|
||
default=14,
|
||
ge=1,
|
||
validation_alias=AliasChoices("LOG_RETENTION_DAYS", "log_retention_days"),
|
||
)
|
||
#: 子进程整段输出根目录(实际写入 ``{log_subprocess_dir}/{measure|action}/subprocess/...``)。**LOG_SUBPROCESS_DIR**
|
||
log_subprocess_dir: Path = Field(
|
||
default_factory=_default_log_subprocess_dir,
|
||
validation_alias=AliasChoices("LOG_SUBPROCESS_DIR", "log_subprocess_dir"),
|
||
)
|
||
#: 子进程结束摘要在主日志里保留的末尾行数。**LOG_SUBPROCESS_TAIL_LINES**
|
||
log_subprocess_tail_lines: int = Field(
|
||
default=30,
|
||
ge=0,
|
||
validation_alias=AliasChoices(
|
||
"LOG_SUBPROCESS_TAIL_LINES", "log_subprocess_tail_lines"
|
||
),
|
||
)
|
||
|
||
@field_validator("zed_serial_number", mode="before")
|
||
@classmethod
|
||
def _zed_serial_empty_none(cls, v: object) -> object:
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, str) and not v.strip():
|
||
return None
|
||
return v
|
||
|
||
@field_validator("biomass_sonar_slice_order", mode="before")
|
||
@classmethod
|
||
def _normalize_biomass_sonar_slice_order(cls, v: object) -> str:
|
||
if v is None:
|
||
return "sequential"
|
||
s = str(v).strip().lower()
|
||
if s in frozenset({"tail", "last", "last_n", "sseof"}):
|
||
return "tail"
|
||
if s in frozenset({"sequential", "seq", "from_start"}):
|
||
return "sequential"
|
||
raise ValueError(
|
||
"biomass_sonar_slice_order must be 'tail' or 'sequential' "
|
||
f"(got {v!r})"
|
||
)
|
||
|
||
@field_validator(
|
||
"action_watch_dir",
|
||
"biomass_water_video_source",
|
||
"biomass_sonar_video_source",
|
||
"biomass_sonar_video_dir",
|
||
"measure_watch_dir",
|
||
"zed_svo_record_dir",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _empty_str_path_none(cls, v: object) -> object:
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, str) and not v.strip():
|
||
return None
|
||
return v
|
||
|
||
@model_validator(mode="after")
|
||
def _default_paths(self) -> "Settings":
|
||
md = models_dir()
|
||
if not self.action_checkpoint:
|
||
object.__setattr__(
|
||
self, "action_checkpoint", str(md / "action_x3d" / "checkpoint_best.pt")
|
||
)
|
||
if self.measure_pointcloud_classifier is None:
|
||
object.__setattr__(
|
||
self,
|
||
"measure_pointcloud_classifier",
|
||
self.fish_measure_root
|
||
/ "pointcloud_classifier"
|
||
/ "Pointnet_Pointnet2_pytorch"
|
||
/ "log"
|
||
/ "classification"
|
||
/ "fish_pointnet2_finetune"
|
||
/ "checkpoints"
|
||
/ "best_model.pth",
|
||
)
|
||
if self.zed_svo_record_dir is None:
|
||
if self.measure_watch_dir is not None:
|
||
object.__setattr__(
|
||
self,
|
||
"zed_svo_record_dir",
|
||
(
|
||
self.measure_watch_dir / f"fish{self.zed_svo_record_fish_id}"
|
||
).resolve(),
|
||
)
|
||
else:
|
||
object.__setattr__(
|
||
self,
|
||
"zed_svo_record_dir",
|
||
(self.stream_tmp_dir / "zed_svo2").resolve(),
|
||
)
|
||
else:
|
||
object.__setattr__(
|
||
self, "zed_svo_record_dir", self.zed_svo_record_dir.expanduser().resolve()
|
||
)
|
||
return self
|
||
|
||
|
||
@lru_cache
|
||
def get_settings() -> Settings:
|
||
return Settings()
|