From 8f4e86e47679843fc9ed6a9a56011b3c52a0198c Mon Sep 17 00:00:00 2001 From: GuoQing Liu <842607283@qq.com> Date: Mon, 17 Mar 2025 22:01:07 +0800 Subject: [PATCH 1/7] chinese i18n fix (#17190) * fix: fix dialog some key capitalization * chore: remove audio duplicate key. (i18next can't use it! maybe need change key name? ) * feat: add chinese missing keys. fix: fix some keys error * feat: add chinese readme file * feat: add system feature pages embeddings i18n keys * fix: fix audio file keys wrong --- README.md | 2 + README_CN.md | 52 +++ web/public/locales/en/views/system.json | 8 +- web/public/locales/zh-CN/audio.json | 429 +++++++++++++++++- web/public/locales/zh-CN/common.json | 8 +- .../locales/zh-CN/components/dialog.json | 2 +- .../locales/zh-CN/components/input.json | 2 +- web/public/locales/zh-CN/objects.json | 18 +- web/public/locales/zh-CN/views/explore.json | 4 +- web/public/locales/zh-CN/views/live.json | 8 +- web/public/locales/zh-CN/views/search.json | 12 +- web/public/locales/zh-CN/views/settings.json | 2 +- web/public/locales/zh-CN/views/system.json | 8 +- web/src/views/system/FeatureMetrics.tsx | 4 +- 14 files changed, 531 insertions(+), 28 deletions(-) create mode 100644 README_CN.md diff --git a/README.md b/README.md index 5b67c86c3..b15bb5c2f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # Frigate - NVR With Realtime Object Detection for IP Cameras +\[English\] | [简体中文](https://github.com/blakeblackshear/frigate/README_CN.md) + A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead. diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 000000000..6f4b5c4ed --- /dev/null +++ b/README_CN.md @@ -0,0 +1,52 @@ +

+ logo +

+ +# Frigate - 一个具有实时目标检测的本地NVR + +[English](https://github.com/blakeblackshear/frigate) | \[简体中文\] + +一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。 + +强烈推荐使用可选配件:[Google Coral加速器](https://coral.ai/products/)。在该场景下,Coral的性能甚至超过目前的顶级CPU,并且可以以极低的电力开销轻松处理100 以上的画面帧。 + +- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成 +- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能 +- 大量利用多进程处理,强调实时性而非处理每一帧 +- 使用非常低开销的运动检测来确定运行物体检测的位置 +- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS +- 通过MQTT进行通信,便于集成到其他系统中 +- 根据检测到的物体设置保留时间进行视频录制 +- 24/7全天候录制 +- 通过RTSP重新流传输以减少摄像头的连接数 +- 支持WebRTC和MSE,实现低延迟的实时观看 + +## 文档(英文) + +你可以在这里查看文档 https://docs.frigate.video + +## 赞助 + +如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。 + +## 截图 + +### 实时监控面板 +
+实时监控面板 +
+ +### 简单的审查工作流程 +
+简单的审查工作流程 +
+ +### 多摄像头可按时间轴查看 +
+多摄像头可按时间轴查看 +
+ +### 内置遮罩和区域编辑器 +
+内置遮罩和区域编辑器 +
diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 65b2db663..77516f3e1 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -145,6 +145,12 @@ "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)" }, "features": { - "title": "Features" + "title": "Features", + "embeddings": { + "image_embedding_speed": "Image Embedding Speed", + "face_embedding_speed": "Face Embedding Speed", + "plate_recognition_speed": "Plate Recognition Speed", + "text_embedding_speed": "Text Embedding Speed" + } } } diff --git a/web/public/locales/zh-CN/audio.json b/web/public/locales/zh-CN/audio.json index f9438a747..2c65847e3 100644 --- a/web/public/locales/zh-CN/audio.json +++ b/web/public/locales/zh-CN/audio.json @@ -1,8 +1,429 @@ { - "crying": "哭泣", - "laughter": "笑声", - "scream": "尖叫", "speech": "谈话", + "babbling": "喋喋不休", "yell": "大喊", - "fire_alarm": "火灾警报器" + "bellow": "吼叫", + "whoop": "欢呼", + "whispering": "耳语", + "laughter": "笑声", + "snicker": "窃笑", + "crying": "哭泣", + "sigh": "叹息", + "singing": "唱歌", + "choir": "合唱", + "yodeling": "山歌", + "chant": "吟唱", + "mantra": "咒语", + "child_singing": "儿童歌唱", + "synthetic_singing": "合成歌声", + "rapping": "说唱", + "humming": "哼唱", + "groan": "呻吟", + "grunt": "咕哝", + "whistling": "口哨", + "breathing": "呼吸", + "wheeze": "喘息", + "snoring": "打鼾", + "gasp": "倒抽气", + "pant": "喘气", + "snort": "哼声", + "cough": "咳嗽", + "throat_clearing": "清嗓子", + "sneeze": "打喷嚏", + "sniff": "抽鼻子", + "run": "跑步", + "shuffle": "拖步", + "footsteps": "脚步声", + "chewing": "咀嚼", + "biting": "咬", + "gargling": "漱口", + "stomach_rumble": "肚子咕噜", + "burping": "打嗝", + "hiccup": "打嗝", + "fart": "放屁", + "hands": "手", + "finger_snapping": "打响指", + "clapping": "鼓掌", + "heartbeat": "心跳", + "heart_murmur": "心脏杂音", + "cheering": "欢呼", + "applause": "掌声", + "chatter": "闲聊", + "crowd": "人群", + "children_playing": "儿童玩耍", + "animal": "动物", + "pets": "宠物", + "dog": "狗", + "bark": "吠叫", + "yip": "吠叫", + "howl": "嚎叫", + "bow_wow": "汪汪", + "growling": "咆哮", + "whimper_dog": "狗呜咽", + "cat": "猫", + "purr": "咕噜", + "meow": "喵喵", + "hiss": "嘶嘶声", + "caterwaul": "猫叫春", + "livestock": "牲畜", + "horse": "马", + "clip_clop": "蹄声", + "neigh": "嘶鸣", + "cattle": "牛", + "moo": "哞哞", + "cowbell": "牛铃", + "pig": "猪", + "oink": "哼哼", + "goat": "山羊", + "bleat": "咩咩", + "sheep": "绵羊", + "fowl": "家禽", + "chicken": "鸡", + "cluck": "咯咯", + "cock_a_doodle_doo": "喔喔", + "turkey": "火鸡", + "gobble": "咯咯", + "duck": "鸭子", + "quack": "嘎嘎", + "goose": "鹅", + "honk": "鸣笛/鹅叫声", + "wild_animals": "野生动物", + "roaring_cats": "吼叫的猫科动物", + "roar": "吼叫", + "bird": "鸟", + "chirp": "啾啾", + "squawk": "啼叫", + "pigeon": "鸽子", + "coo": "咕咕", + "crow": "乌鸦", + "caw": "呱呱", + "owl": "猫头鹰", + "hoot": "呜呜", + "flapping_wings": "翅膀拍打", + "dogs": "狗群", + "rats": "老鼠", + "mouse": "老鼠", + "patter": "啪嗒声", + "insect": "昆虫", + "cricket": "蟋蟀", + "mosquito": "蚊子", + "fly": "苍蝇", + "buzz": "嗡嗡", + "frog": "青蛙", + "croak": "呱呱", + "snake": "蛇", + "rattle": "响尾", + "whale_vocalization": "鲸鱼叫声", + "music": "音乐", + "musical_instrument": "乐器", + "plucked_string_instrument": "弹拨乐器", + "guitar": "吉他", + "electric_guitar": "电吉他", + "bass_guitar": "贝斯", + "acoustic_guitar": "原声吉他", + "steel_guitar": "钢弦吉他", + "tapping": "敲击", + "strum": "扫弦", + "banjo": "班卓琴", + "sitar": "西塔琴", + "mandolin": "曼陀林", + "zither": "古筝", + "ukulele": "尤克里里", + "keyboard": "键盘", + "piano": "钢琴", + "electric_piano": "电钢琴", + "organ": "风琴", + "electronic_organ": "电子琴", + "hammond_organ": "哈蒙德风琴", + "synthesizer": "合成器", + "sampler": "采样器", + "harpsichord": "大键琴", + "percussion": "打击乐器", + "drum_kit": "架子鼓", + "drum_machine": "鼓机", + "drum": "鼓", + "snare_drum": "军鼓", + "rimshot": "鼓边击", + "drum_roll": "滚鼓", + "bass_drum": "大鼓", + "timpani": "定音鼓", + "tabla": "塔布拉鼓", + "cymbal": "钹", + "hi_hat": "踩镲", + "wood_block": "木鱼", + "tambourine": "铃鼓", + "maraca": "沙锤", + "gong": "锣", + "tubular_bells": "管钟", + "mallet_percussion": "槌击打击乐器", + "marimba": "马林巴", + "glockenspiel": "钟琴", + "vibraphone": "颤音琴", + "steelpan": "钢鼓", + "orchestra": "管弦乐队", + "brass_instrument": "铜管乐器", + "french_horn": "圆号", + "trumpet": "小号", + "trombone": "长号", + "bowed_string_instrument": "弓弦乐器", + "string_section": "弦乐组", + "violin": "小提琴", + "pizzicato": "拨弦", + "cello": "大提琴", + "double_bass": "低音提琴", + "wind_instrument": "管乐器", + "flute": "长笛", + "saxophone": "萨克斯", + "clarinet": "单簧管", + "harp": "竖琴", + "bell": "铃", + "church_bell": "教堂钟", + "jingle_bell": "铃铛", + "bicycle_bell": "自行车铃", + "tuning_fork": "音叉", + "chime": "风铃", + "wind_chime": "风铃", + "harmonica": "口琴", + "accordion": "手风琴", + "bagpipes": "风笛", + "didgeridoo": "迪吉里杜管", + "theremin": "特雷门琴", + "singing_bowl": "颂钵", + "scratching": "刮擦声", + "pop_music": "流行音乐", + "hip_hop_music": "嘻哈音乐", + "beatboxing": "人声节拍", + "rock_music": "摇滚音乐", + "heavy_metal": "重金属", + "punk_rock": "朋克摇滚", + "grunge": "垃圾摇滚", + "progressive_rock": "前卫摇滚", + "rock_and_roll": "摇滚乐", + "psychedelic_rock": "迷幻摇滚", + "rhythm_and_blues": "节奏布鲁斯", + "soul_music": "灵魂乐", + "reggae": "雷鬼", + "country": "乡村音乐", + "swing_music": "摇摆乐", + "bluegrass": "蓝草音乐", + "funk": "放克", + "folk_music": "民谣", + "middle_eastern_music": "中东音乐", + "jazz": "爵士乐", + "disco": "迪斯科", + "classical_music": "古典音乐", + "opera": "歌剧", + "electronic_music": "电子音乐", + "house_music": "浩室音乐", + "techno": "科技舞曲", + "dubstep": "回响贝斯", + "drum_and_bass": "鼓打贝斯", + "electronica": "电子乐", + "electronic_dance_music": "电子舞曲", + "ambient_music": "环境音乐", + "trance_music": "迷幻舞曲", + "music_of_latin_america": "拉丁美洲音乐", + "salsa_music": "萨尔萨", + "flamenco": "弗拉门戈", + "blues": "蓝调", + "music_for_children": "儿童音乐", + "new-age_music": "新世纪音乐", + "vocal_music": "声乐", + "a_capella": "无伴奏合唱", + "music_of_africa": "非洲音乐", + "afrobeat": "非洲节拍", + "christian_music": "基督教音乐", + "gospel_music": "福音音乐", + "music_of_asia": "亚洲音乐", + "carnatic_music": "卡纳提克音乐", + "music_of_bollywood": "宝莱坞音乐", + "ska": "斯卡", + "traditional_music": "传统音乐", + "independent_music": "独立音乐", + "song": "歌曲", + "background_music": "背景音乐", + "theme_music": "主题音乐", + "jingle": "广告歌", + "soundtrack_music": "配乐", + "lullaby": "摇篮曲", + "video_game_music": "电子游戏音乐", + "christmas_music": "圣诞音乐", + "dance_music": "舞曲", + "wedding_music": "婚礼音乐", + "happy_music": "欢快音乐", + "sad_music": "悲伤音乐", + "tender_music": "温柔音乐", + "exciting_music": "激动音乐", + "angry_music": "愤怒音乐", + "scary_music": "恐怖音乐", + "wind": "风", + "rustling_leaves": "树叶沙沙声", + "wind_noise": "风声", + "thunderstorm": "雷暴", + "thunder": "雷声", + "water": "水", + "rain": "雨", + "raindrop": "雨滴", + "rain_on_surface": "雨打表面", + "stream": "溪流", + "waterfall": "瀑布", + "ocean": "海洋", + "waves": "波浪", + "steam": "蒸汽", + "gurgling": "汩汩声", + "fire": "火", + "crackle": "噼啪声", + "vehicle": "车辆", + "boat": "船", + "sailboat": "帆船", + "rowboat": "划艇", + "motorboat": "摩托艇", + "ship": "轮船", + "motor_vehicle": "机动车", + "car": "汽车", + "toot": "鸣笛", + "car_alarm": "汽车警报", + "power_windows": "电动车窗", + "skidding": "轮胎打滑", + "tire_squeal": "轮胎尖叫", + "car_passing_by": "汽车驶过", + "race_car": "赛车", + "truck": "卡车", + "air_brake": "气闸", + "air_horn": "气笛", + "reversing_beeps": "倒车提示音", + "ice_cream_truck": "冰淇淋车", + "bus": "公共汽车", + "emergency_vehicle": "应急车辆", + "police_car": "警车", + "ambulance": "救护车", + "fire_engine": "消防车", + "motorcycle": "摩托车", + "traffic_noise": "交通噪音", + "rail_transport": "铁路运输", + "train": "火车", + "train_whistle": "火车汽笛", + "train_horn": "火车鸣笛", + "railroad_car": "铁路车厢", + "train_wheels_squealing": "火车轮子尖叫", + "subway": "地铁", + "aircraft": "飞行器", + "aircraft_engine": "飞机引擎", + "jet_engine": "喷气引擎", + "propeller": "螺旋桨", + "helicopter": "直升机", + "fixed-wing_aircraft": "固定翼飞机", + "bicycle": "自行车", + "skateboard": "滑板", + "engine": "引擎", + "light_engine": "轻型引擎", + "dental_drill's_drill": "牙科钻", + "lawn_mower": "割草机", + "chainsaw": "电锯", + "medium_engine": "中型引擎", + "heavy_engine": "重型引擎", + "engine_knocking": "引擎敲击", + "engine_starting": "引擎启动", + "idling": "怠速", + "accelerating": "加速", + "door": "门", + "doorbell": "门铃", + "ding-dong": "叮咚", + "sliding_door": "滑动门", + "slam": "猛关", + "knock": "敲门", + "tap": "轻敲", + "squeak": "吱吱声", + "cupboard_open_or_close": "橱柜开关", + "drawer_open_or_close": "抽屉开关", + "dishes": "餐具", + "cutlery": "刀叉", + "chopping": "切菜", + "frying": "煎炸", + "microwave_oven": "微波炉", + "blender": "搅拌机", + "water_tap": "水龙头", + "sink": "水槽", + "bathtub": "浴缸", + "hair_dryer": "吹风机", + "toilet_flush": "马桶冲水", + "toothbrush": "牙刷", + "electric_toothbrush": "电动牙刷", + "vacuum_cleaner": "吸尘器", + "zipper": "拉链", + "keys_jangling": "钥匙叮当", + "coin": "硬币", + "scissors": "剪刀", + "electric_shaver": "电动剃须刀", + "shuffling_cards": "洗牌", + "typing": "打字", + "typewriter": "打字机", + "computer_keyboard": "电脑键盘", + "writing": "书写", + "alarm": "警报", + "telephone": "电话", + "telephone_bell_ringing": "电话铃声", + "ringtone": "手机铃声", + "telephone_dialing": "电话拨号", + "dial_tone": "拨号音", + "busy_signal": "忙音", + "alarm_clock": "闹钟", + "siren": "警笛", + "civil_defense_siren": "防空警报", + "buzzer": "蜂鸣器", + "smoke_detector": "烟雾探测器", + "fire_alarm": "火灾警报器", + "foghorn": "雾笛", + "whistle": "哨子", + "steam_whistle": "蒸汽汽笛", + "mechanisms": "机械装置", + "ratchet": "棘轮", + "clock": "时钟", + "tick": "滴答", + "tick-tock": "滴答滴答", + "gears": "齿轮", + "pulleys": "滑轮", + "sewing_machine": "缝纫机", + "mechanical_fan": "机械风扇", + "air_conditioning": "空调", + "cash_register": "收银机", + "printer": "打印机", + "camera": "相机", + "single-lens_reflex_camera": "单反相机", + "tools": "工具", + "hammer": "锤子", + "jackhammer": "风镐", + "sawing": "锯", + "filing": "锉", + "sanding": "砂磨", + "power_tool": "电动工具", + "drill": "电钻", + "explosion": "爆炸", + "gunshot": "枪声", + "machine_gun": "机关枪", + "fusillade": "齐射", + "artillery_fire": "炮火", + "cap_gun": "玩具枪", + "fireworks": "烟花", + "firecracker": "鞭炮", + "burst": "爆裂", + "eruption": "爆发", + "boom": "轰隆", + "wood": "木头", + "chop": "砍", + "splinter": "碎裂", + "crack": "破裂", + "glass": "玻璃", + "chink": "叮当", + "shatter": "粉碎", + "silence": "寂静", + "sound_effect": "音效", + "environmental_noise": "环境噪音", + "static": "静电噪音", + "white_noise": "白噪音", + "pink_noise": "粉红噪音", + "television": "电视", + "radio": "收音机", + "field_recording": "实地录音", + "scream": "尖叫" } diff --git a/web/public/locales/zh-CN/common.json b/web/public/locales/zh-CN/common.json index ef56a0c52..0da7064b1 100644 --- a/web/public/locales/zh-CN/common.json +++ b/web/public/locales/zh-CN/common.json @@ -20,8 +20,8 @@ "1hour": "1 小时", "12hours": "12 小时", "24hours": "24 小时", - "pm": "上午", - "am": "下午", + "pm": "下午", + "am": "上午", "yr": "{{time}}年", "year": "{{time}}年", "mo": "{{time}}月", @@ -88,7 +88,7 @@ "back": "返回", "history": "历史", "fullscreen": "全屏", - "exitFullscreen": "全屏", + "exitFullscreen": "退出全屏", "pictureInPicture": "画中画", "on": "开", "off": "关", @@ -157,7 +157,7 @@ "review": "回放", "explore": "探测", "export": "导出", - "uiPlayground": "UI Playground", + "uiPlayground": "UI 演示", "faceLibrary": "人脸管理", "user": { "account": "账号", diff --git a/web/public/locales/zh-CN/components/dialog.json b/web/public/locales/zh-CN/components/dialog.json index da7786483..92c574f12 100644 --- a/web/public/locales/zh-CN/components/dialog.json +++ b/web/public/locales/zh-CN/components/dialog.json @@ -71,7 +71,7 @@ "streaming": { "label": "视频流", "restreaming": { - "disabled": "重新流式传输未启用。", + "disabled": "此摄像头未启用视频流转发功能。", "desc": { "title": "为此摄像头设置 go2rtc,以获取额外的实时预览选项和音频支持。", "readTheDocumentation": "阅读文档(英文) " diff --git a/web/public/locales/zh-CN/components/input.json b/web/public/locales/zh-CN/components/input.json index 006aca13e..767aea545 100644 --- a/web/public/locales/zh-CN/components/input.json +++ b/web/public/locales/zh-CN/components/input.json @@ -3,7 +3,7 @@ "downloadVideo": { "label": "下载视频", "toast": { - "success": "下载成功" + "success": "您的回放视频已开始下载。" } } } diff --git a/web/public/locales/zh-CN/objects.json b/web/public/locales/zh-CN/objects.json index 6c0fe7fbd..80a7893ae 100644 --- a/web/public/locales/zh-CN/objects.json +++ b/web/public/locales/zh-CN/objects.json @@ -100,5 +100,21 @@ "raccoon": "浣熊", "robot_lawnmower": "自动割草机", "waste_bin": "垃圾桶", - "on_demand": "手动" + "on_demand": "手动", + "face": "人脸", + "license_plate": "车牌", + "package": "包裹", + "bbq_grill": "烧烤架", + "amazon": "亚马逊", + "usps": "美国邮政", + "ups": "UPS", + "fedex": "联邦快递", + "dhl": "DHL", + "an_post": "爱尔兰邮政", + "purolator": "普罗莱特", + "postnl": "荷兰邮政", + "nzpost": "新西兰邮政", + "postnord": "北欧邮政", + "gls": "GLS", + "dpd": "DPD" } diff --git a/web/public/locales/zh-CN/views/explore.json b/web/public/locales/zh-CN/views/explore.json index 9e345d3a2..e855cb0d9 100644 --- a/web/public/locales/zh-CN/views/explore.json +++ b/web/public/locales/zh-CN/views/explore.json @@ -29,7 +29,7 @@ "error": "发生错误。请检查Frigate日志。" } }, - "trackedObjectDetails": "探测对象详情", + "trackedObjectDetails": "跟踪对象详情", "type": { "details": "详情", "snapshot": "快照", @@ -169,7 +169,7 @@ "desc": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频不会被删除。

你确定要继续删除吗?" } }, - "noTrackedObjects": "找不到探测的对象", + "noTrackedObjects": "未找到跟踪对象", "fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}", "trackedObjectsCount": "{{count}} 个跟踪对象", "searchResult": { diff --git a/web/public/locales/zh-CN/views/live.json b/web/public/locales/zh-CN/views/live.json index bfaf0ea42..a68518547 100644 --- a/web/public/locales/zh-CN/views/live.json +++ b/web/public/locales/zh-CN/views/live.json @@ -79,14 +79,14 @@ }, "manualRecording": { "title": "按需录制", - "tips": "根据此摄像机的录制保留设置,手动启动事件。", + "tips": "根据此摄像头的录制保留设置,手动启动事件。", "playInBackground": { "label": "后台播放", "desc": "启用此选项可在播放器隐藏时继续视频流播放。" }, "showStats": { "label": "显示统计信息", - "desc": "启用此选项可在摄像机画面上叠加显示视频流统计信息。" + "desc": "启用此选项可在摄像头画面上叠加显示视频流统计信息。" }, "debugView": "调试视图", "start": "开始手动按需录制", @@ -107,7 +107,7 @@ "title": "视频流", "audio": { "tips": { - "title": "音频必须从摄像机输出并在 go2rtc 中配置为此视频流使用。", + "title": "音频必须从摄像头输出并在 go2rtc 中配置为此视频流使用。", "documentation": "阅读文档 " }, "available": "此视频流支持音频", @@ -130,7 +130,7 @@ }, "cameraSettings": { "title": "{{camera}} 设置", - "cameraEnabled": "摄像机已启用", + "cameraEnabled": "摄像头已启用", "objectDetection": "对象检测", "recording": "录制", "snapshots": "快照", diff --git a/web/public/locales/zh-CN/views/search.json b/web/public/locales/zh-CN/views/search.json index bd98bbabb..d55a611b9 100644 --- a/web/public/locales/zh-CN/views/search.json +++ b/web/public/locales/zh-CN/views/search.json @@ -34,12 +34,12 @@ }, "toast": { "error": { - "beforeDateBeLaterAfter": "“之前”日期必须晚于“之后”日期。", - "afterDatebeEarlierBefore": "“之后”日期必须早于“之前”日期。", - "minScoreMustBeLessOrEqualMaxScore": "最小分值 必须小于或等于 最大分值。", - "maxScoreMustBeGreaterOrEqualMinScore": "最大分值 必须大于或等于 最小分值", - "minSpeedMustBeLessOrEqualMaxSpeed": "最低速度 必须小于或等于 最高速度", - "maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度 必须大于或等于 最低速度" + "beforeDateBeLaterAfter": "结束日期必须晚于开始日期。", + "afterDatebeEarlierBefore": "开始日期必须早于结束日期。", + "minScoreMustBeLessOrEqualMaxScore": "最低分数必须小于或等于最高分数。", + "maxScoreMustBeGreaterOrEqualMinScore": "最高分数必须大于或等于最低分数。", + "minSpeedMustBeLessOrEqualMaxSpeed": "最低速度必须小于或等于最高速度。", + "maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度必须大于或等于最低速度。" } }, "tips": { diff --git a/web/public/locales/zh-CN/views/settings.json b/web/public/locales/zh-CN/views/settings.json index 0fc96128f..8fcfb869e 100644 --- a/web/public/locales/zh-CN/views/settings.json +++ b/web/public/locales/zh-CN/views/settings.json @@ -321,7 +321,7 @@ }, "improveContrast": { "title": "提高对比度", - "desc": "提高较暗场景的对比度。默认值:开启" + "desc": "提高较暗场景的对比度。默认值:启用" }, "toast": { "success": "运动设置已保存。" diff --git a/web/public/locales/zh-CN/views/system.json b/web/public/locales/zh-CN/views/system.json index 20fbeee83..01251f3c5 100644 --- a/web/public/locales/zh-CN/views/system.json +++ b/web/public/locales/zh-CN/views/system.json @@ -145,6 +145,12 @@ "reindexingEmbeddings": "正在重新索引嵌入(已完成 {{processed}}%)" }, "features": { - "title": "功能" + "title": "功能", + "embeddings": { + "image_embedding_speed": "图像特征提取速度", + "face_embedding_speed": "人脸特征提取速度", + "plate_recognition_speed": "车牌识别速度", + "text_embedding_speed": "文本编码速度" + } } } diff --git a/web/src/views/system/FeatureMetrics.tsx b/web/src/views/system/FeatureMetrics.tsx index 0f6096a7f..c5b6e1454 100644 --- a/web/src/views/system/FeatureMetrics.tsx +++ b/web/src/views/system/FeatureMetrics.tsx @@ -76,14 +76,14 @@ export default function FeatureMetrics({ const key = rawKey.replaceAll("_", " "); if (!(key in series)) { - series[key] = { name: key, data: [] }; + series[key] = { name: t("features.embeddings." + rawKey), data: [] }; } series[key].data.push({ x: statsIdx + 1, y: stat }); }); }); return Object.values(series); - }, [statsHistory]); + }, [statsHistory, t]); return ( <> From 95e141ed15f1eb4b5d6a049fb8157a5933fc68bb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 17 Mar 2025 08:05:53 -0600 Subject: [PATCH 2/7] Improve face detection (#17202) --- frigate/data_processing/real_time/face.py | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index 7d97f8586..e70801812 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -27,6 +27,7 @@ from .api import RealTimeProcessorApi logger = logging.getLogger(__name__) +MAX_DETECTION_HEIGHT = 1080 MIN_MATCHING_FACES = 2 @@ -88,7 +89,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), config="", input_size=(320, 320), - score_threshold=self.face_config.detection_threshold, + score_threshold=0.5, nms_threshold=0.3, ) self.landmark_detector = cv2.face.createFacemarkLBF() @@ -212,11 +213,21 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.face_recognizer = None self.label_map = {} - def __detect_face(self, input: np.ndarray) -> tuple[int, int, int, int]: + def __detect_face( + self, input: np.ndarray, threshold: float + ) -> tuple[int, int, int, int]: """Detect faces in input image.""" if not self.face_detector: return None + # YN face detector fails at extreme definitions + # this rescales to a size that can properly detect faces + # still retaining plenty of detail + if input.shape[0] > MAX_DETECTION_HEIGHT: + scale_factor = MAX_DETECTION_HEIGHT / input.shape[0] + new_width = int(scale_factor * input.shape[1]) + input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT)) + self.face_detector.setInputSize((input.shape[1], input.shape[0])) faces = self.face_detector.detect(input) @@ -226,6 +237,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): face = None for _, potential_face in enumerate(faces[1]): + if potential_face[-1] < threshold: + continue + raw_bbox = potential_face[0:4].astype(np.uint16) x: int = max(raw_bbox[0], 0) y: int = max(raw_bbox[1], 0) @@ -300,7 +314,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) left, top, right, bottom = person_box person = rgb[top:bottom, left:right] - face_box = self.__detect_face(person) + face_box = self.__detect_face(person, self.face_config.detection_threshold) if not face_box: logger.debug("Detected no faces for person object.") @@ -406,7 +420,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): ), cv2.IMREAD_COLOR, ) - face_box = self.__detect_face(img) + + # detect faces with lower confidence since we expect the face + # to be visible in uploaded images + face_box = self.__detect_face(img, 0.5) if not face_box: return { From 61aef0bff0f2f80a92d69a723079c88df8c150e1 Mon Sep 17 00:00:00 2001 From: bartbutenaers Date: Mon, 17 Mar 2025 16:29:38 +0100 Subject: [PATCH 3/7] Base path via environment variable (#17030) * Base path via environment variable * Feedback refactored * Update docker/main/rootfs/usr/local/nginx/conf/nginx.conf Co-authored-by: Blake Blackshear * Update docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl Co-authored-by: Blake Blackshear * Revert api regex change * Lint fix * Fix https to http * Base path documentation * Base path contains leading slash * Frigate specific environment variable * Typo in comment Co-authored-by: Blake Blackshear * Typo in comment Co-authored-by: Blake Blackshear --------- Co-authored-by: Blake Blackshear --- .../rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 5 +++ .../rootfs/usr/local/nginx/conf/nginx.conf | 13 ++++++++ .../rootfs/usr/local/nginx/get_base_path.py | 10 ++++++ .../local/nginx/templates/base_path.gotmpl | 19 +++++++++++ docs/docs/configuration/advanced.md | 32 +++++++++++++++++++ 5 files changed, 79 insertions(+) create mode 100644 docker/main/rootfs/usr/local/nginx/get_base_path.py create mode 100644 docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index 677126a6d..273182930 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -79,6 +79,11 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain. -keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null fi +# build templates for optional FRIGATE_BASE_PATH environment variable +python3 /usr/local/nginx/get_base_path.py | \ + tempio -template /usr/local/nginx/templates/base_path.gotmpl \ + -out /usr/local/nginx/conf/base_path.conf + # build templates for optional TLS support python3 /usr/local/nginx/get_tls_settings.py | \ tempio -template /usr/local/nginx/templates/listen.gotmpl \ diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 8a98da1f2..64d6396b2 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -96,6 +96,7 @@ http { gzip_types application/vnd.apple.mpegurl; include auth_location.conf; + include base_path.conf; location /vod/ { include auth_request.conf; @@ -299,6 +300,18 @@ http { add_header Cache-Control "public"; } + location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ { + access_log off; + expires 1y; + add_header Cache-Control "public"; + default_type application/json; + proxy_set_header Accept-Encoding ""; + sub_filter_once off; + sub_filter_types application/json; + sub_filter '"start_url": "/"' '"start_url" : "$http_x_ingress_path"'; + sub_filter '"src": "/' '"src": "$http_x_ingress_path/'; + } + sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/'; sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/'; sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/'; diff --git a/docker/main/rootfs/usr/local/nginx/get_base_path.py b/docker/main/rootfs/usr/local/nginx/get_base_path.py new file mode 100644 index 000000000..e6fc8cfc6 --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/get_base_path.py @@ -0,0 +1,10 @@ +"""Prints the base path as json to stdout.""" + +import json +import os + +base_path = os.environ.get("FRIGATE_BASE_PATH", "") + +result: dict[str, any] = {"base_path": base_path} + +print(json.dumps(result)) diff --git a/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl new file mode 100644 index 000000000..ace4443ee --- /dev/null +++ b/docker/main/rootfs/usr/local/nginx/templates/base_path.gotmpl @@ -0,0 +1,19 @@ +{{ if .base_path }} +location = {{ .base_path }} { + return 302 {{ .base_path }}/; +} + +location ^~ {{ .base_path }}/ { + # remove base_url from the path before passing upstream + rewrite ^{{ .base_path }}/(.*) /$1 break; + + proxy_pass $scheme://127.0.0.1:8971; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Ingress-Path {{ .base_path }}; + + access_log off; +} +{{ end }} diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced.md index c889d2d26..1e128e0e3 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -172,6 +172,38 @@ listen [::]:8971 ipv6only=off ssl; listen [::]:5000 ipv6only=off; ``` +## Base path + +By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing. + +### Set Base Path via HTTP Header +The preferred way to configure the base path is through the `X-Ingress-Path` HTTP header, which needs to be set to the desired base path in an upstream reverse proxy. + +For example, in Nginx: +``` +location /frigate { + proxy_set_header X-Ingress-Path /frigate; + proxy_pass http://frigate_backend; +} +``` + +### Set Base Path via Environment Variable +When it is not feasible to set the base path via a HTTP header, it can also be set via the `FRIGATE_BASE_PATH` environment variable in the Docker Compose file. + +For example: +``` +services: + frigate: + image: blakeblackshear/frigate:latest + environment: + - FRIGATE_BASE_PATH=/frigate +``` + +This can be used for example to access Frigate via a Tailscale agent (https), by simply forwarding all requests to the base path (http): +``` +tailscale serve --https=443 --bg --set-path /frigate http://localhost:5000/frigate +``` + ## Custom Dependencies ### Custom ffmpeg build From fad62b996ad9b66420128265f65ce1d035c25db0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 17 Mar 2025 13:44:57 -0500 Subject: [PATCH 4/7] Add Frigate+ pane to Settings UI (#17208) * add plus data to config api response * add fields to frontend type * add frigate+ page in settings * add docs * fix label in explore detail dialog --- docs/docs/plus/faq.md | 10 + frigate/api/app.py | 13 + web/public/locales/en/views/settings.json | 37 ++- .../overlay/detail/SearchDetailDialog.tsx | 2 +- web/src/pages/Settings.tsx | 3 + web/src/types/frigateConfig.ts | 6 + .../settings/FrigatePlusSettingsView.tsx | 229 ++++++++++++++++++ 7 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 web/src/views/settings/FrigatePlusSettingsView.tsx diff --git a/docs/docs/plus/faq.md b/docs/docs/plus/faq.md index fb0cd2512..151eb3f60 100644 --- a/docs/docs/plus/faq.md +++ b/docs/docs/plus/faq.md @@ -22,3 +22,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co ### Can I keep using my Frigate+ models even if I do not renew my subscription? Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models. + +### Why can't I submit images to Frigate+? + +If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled. + +```yaml +snapshots: + enabled: true + clean_copy: true +``` diff --git a/frigate/api/app.py b/frigate/api/app.py index 05013ed12..9d7b3768f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -9,6 +9,7 @@ import traceback from datetime import datetime, timedelta from functools import reduce from io import StringIO +from pathlib import Path as FilePath from typing import Any, Optional import aiofiles @@ -174,6 +175,18 @@ def config(request: Request): config["model"]["all_attributes"] = config_obj.model.all_attributes config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes + # Add model plus data if plus is enabled + if config["plus"]["enabled"]: + model_json_path = FilePath(config["model"]["path"]).with_suffix(".json") + try: + with open(model_json_path, "r") as f: + model_plus_data = json.load(f) + config["model"]["plus"] = model_plus_data + except FileNotFoundError: + config["model"]["plus"] = None + except json.JSONDecodeError: + config["model"]["plus"] = None + # use merged labelamp for detector_config in config["detectors"].values(): detector_config["model"]["labelmap"] = ( diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index f19ac5ee6..3d25b92c1 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -7,7 +7,8 @@ "masksAndZones": "Mask and Zone Editor - Frigate", "motionTuner": "Motion Tuner - Frigate", "object": "Object Settings - Frigate", - "general": "General Settings - Frigate" + "general": "General Settings - Frigate", + "frigatePlus": "Frigate+ Settings - Frigate" }, "menu": { "uiSettings": "UI Settings", @@ -17,7 +18,8 @@ "motionTuner": "Motion Tuner", "debug": "Debug", "users": "Users", - "notifications": "Notifications" + "notifications": "Notifications", + "frigateplus": "Frigate+" }, "dialog": { "unsavedChanges": { @@ -515,5 +517,36 @@ "registerFailed": "Failed to save notification registration." } } + }, + "frigatePlus": { + "title": "Frigate+ Settings", + "apiKey": { + "title": "Frigate+ API Key", + "validated": "Frigate+ API key is detected and validated", + "notValidated": "Frigate+ API key is not detected or not validated", + "desc": "The Frigate+ API key enables integration with the Frigate+ service.", + "plusLink": "Read more about Frigate+" + }, + "snapshotConfig": { + "title": "Snapshot Configuration", + "desc": "Submitting to Frigate+ requires both snapshots and clean_copy snapshots to be enabled in your config.", + "documentation": "Read the documentation", + "cleanCopyWarning": "Some cameras have snapshots enabled but have the clean copy disabled. You need to enable clean_copy in your snapshot config to be able to submit images from these cameras to Frigate+.", + "table": { + "camera": "Camera", + "snapshots": "Snapshots", + "cleanCopySnapshots": "clean_copy Snapshots" + } + }, + "modelInfo": { + "title": "Model Information", + "modelType": "Model Type", + "trainDate": "Train Date", + "baseModel": "Base Model", + "supportedDetectors": "Supported Detectors", + "cameras": "Cameras", + "loading": "Loading model information...", + "error": "Failed to load model information" + } } } diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index d74efdf6d..891ce88b1 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -563,7 +563,7 @@ function ObjectDetailsTab({
{t("details.label")}
{getIconForLabel(search.label, "size-4 text-primary")} - {t("{search.label}", { ns: "objects" })} + {t(search.label, { ns: "objects" })} {search.sub_label && ` (${search.sub_label})`} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 353d0dbf8..6ccda34f3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -37,6 +37,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; import ClassificationSettingsView from "@/views/settings/ClassificationSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; +import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; @@ -54,6 +55,7 @@ const allSettingsViews = [ "debug", "users", "notifications", + "frigateplus", ] as const; type SettingsType = (typeof allSettingsViews)[number]; @@ -279,6 +281,7 @@ export default function Settings() { {page == "notifications" && ( )} + {page == "frigateplus" && }
{confirmationDialogOpen && ( | null; diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx new file mode 100644 index 000000000..965843f09 --- /dev/null +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -0,0 +1,229 @@ +import Heading from "@/components/ui/heading"; +import { Label } from "@/components/ui/label"; +import { useEffect } from "react"; +import { Toaster } from "sonner"; +import { Separator } from "../../components/ui/separator"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { CheckCircle2, XCircle } from "lucide-react"; +import { Trans, useTranslation } from "react-i18next"; +import { IoIosWarning } from "react-icons/io"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; + +export default function FrigatePlusSettingsView() { + const { data: config } = useSWR("config"); + const { t } = useTranslation("views/settings"); + + useEffect(() => { + document.title = t("documentTitle.frigatePlus"); + }, [t]); + + const needCleanSnapshots = () => { + if (!config) { + return false; + } + return Object.values(config.cameras).some( + (camera) => camera.snapshots.enabled && !camera.snapshots.clean_copy, + ); + }; + + return ( + <> +
+ +
+ + {t("frigatePlus.title")} + + + + + + {t("frigatePlus.apiKey.title")} + + +
+
+
+ {config?.plus?.enabled ? ( + + ) : ( + + )} + +
+
+

{t("frigatePlus.apiKey.desc")}

+ {!config?.model.plus && ( + <> +
+ + {t("frigatePlus.apiKey.plusLink")} + + +
+ + )} +
+
+ + {config?.model.plus && ( + <> + +
+ + {t("frigatePlus.modelInfo.title")} + +
+ {!config?.model?.plus && ( +

+ {t("frigatePlus.modelInfo.loading")} +

+ )} + {config?.model?.plus === null && ( +

+ {t("frigatePlus.modelInfo.error")} +

+ )} + {config?.model?.plus && ( +
+
+ +

{config.model.plus.name}

+
+
+ +

+ {new Date( + config.model.plus.trainDate, + ).toLocaleString()} +

+
+
+ +

{config.model.plus.baseModel}

+
+
+ +

+ {config.model.plus.supportedDetectors.join(", ")} +

+
+
+ )} +
+
+ + )} + + + +
+ + {t("frigatePlus.snapshotConfig.title")} + +
+
+

+ + frigatePlus.snapshotConfig.desc + +

+
+ + {t("frigatePlus.snapshotConfig.documentation")} + + +
+
+ {config && ( +
+ + + + + + + + + + {Object.entries(config.cameras).map( + ([name, camera]) => ( + + + + + + ), + )} + +
+ {t("frigatePlus.snapshotConfig.table.camera")} + + {t("frigatePlus.snapshotConfig.table.snapshots")} + + + frigatePlus.snapshotConfig.table.cleanCopySnapshots + +
{name} + {camera.snapshots.enabled ? ( + + ) : ( + + )} + + {camera.snapshots?.enabled && + camera.snapshots?.clean_copy ? ( + + ) : ( + + )} +
+
+ )} + {needCleanSnapshots() && ( +
+
+ +
+ + frigatePlus.snapshotConfig.cleanCopyWarning + +
+
+
+ )} +
+
+
+
+
+ + ); +} From ff8e145b906d742d43c66c2f18d98704968e4698 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 17 Mar 2025 13:50:13 -0600 Subject: [PATCH 5/7] Face setup wizard (#17203) * Fix login page * Increase face image size and add time ago * Add component for indicating steps in a wizard * Split out form inputs from dialog * Add wizard for adding new face to library * Simplify dialog * Translations * Fix scaling bug * Fix key missing * Improve multi select * Adjust wording and spacing * Add tip for face training * Fix padding * Remove text for buttons on mobile --- frigate/data_processing/real_time/face.py | 10 +- web/public/locales/en/common.json | 4 +- web/public/locales/en/views/faceLibrary.json | 9 +- .../components/indicators/StepIndicator.tsx | 28 +++ web/src/components/input/ImageEntry.tsx | 58 ++++++ web/src/components/input/TextEntry.tsx | 68 +++++++ .../overlay/detail/FaceCreateWizardDialog.tsx | 168 ++++++++++++++++++ .../overlay/dialog/TextEntryDialog.tsx | 79 ++------ .../overlay/dialog/UploadImageDialog.tsx | 63 ++----- web/src/pages/FaceLibrary.tsx | 160 +++++++---------- web/src/pages/LoginPage.tsx | 18 +- 11 files changed, 442 insertions(+), 223 deletions(-) create mode 100644 web/src/components/indicators/StepIndicator.tsx create mode 100644 web/src/components/input/ImageEntry.tsx create mode 100644 web/src/components/input/TextEntry.tsx create mode 100644 web/src/components/overlay/detail/FaceCreateWizardDialog.tsx diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index e70801812..b51b7a20f 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -227,6 +227,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): scale_factor = MAX_DETECTION_HEIGHT / input.shape[0] new_width = int(scale_factor * input.shape[1]) input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT)) + else: + scale_factor = 1 self.face_detector.setInputSize((input.shape[1], input.shape[0])) faces = self.face_detector.detect(input) @@ -241,10 +243,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): continue raw_bbox = potential_face[0:4].astype(np.uint16) - x: int = max(raw_bbox[0], 0) - y: int = max(raw_bbox[1], 0) - w: int = raw_bbox[2] - h: int = raw_bbox[3] + x: int = int(max(raw_bbox[0], 0) / scale_factor) + y: int = int(max(raw_bbox[1], 0) / scale_factor) + w: int = int(raw_bbox[2] / scale_factor) + h: int = int(raw_bbox[3] / scale_factor) bbox = (x, y, x + w, y + h) if face is None or area(bbox) > area(face): diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 4ddd9244e..14b88f707 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -64,6 +64,7 @@ "button": { "apply": "Apply", "reset": "Reset", + "done": "Done", "enabled": "Enabled", "enable": "Enable", "disabled": "Disabled", @@ -94,7 +95,8 @@ "play": "Play", "unselect": "Unselect", "export": "Export", - "deleteNow": "Delete Now" + "deleteNow": "Delete Now", + "next": "Next" }, "menu": { "system": "System", diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index b95f744d7..4028690e3 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -1,4 +1,7 @@ { + "description": { + "addFace": "Walk through adding a new face to the Face Library." + }, "documentTitle": "Face Library - Frigate", "uploadFaceImage": { "title": "Upload Face Image", @@ -6,7 +9,8 @@ }, "createFaceLibrary": { "title": "Create Face Library", - "desc": "Create a new face library" + "desc": "Create a new face library", + "nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle." }, "train": { "title": "Train", @@ -19,12 +23,13 @@ "uploadImage": "Upload Image", "reprocessFace": "Reprocess Face" }, + "readTheDocs": "Read the documentation to view more details on refining images for the Face Library", "trainFaceAs": "Train Face as:", "trainFaceAsPerson": "Train Face as Person", "toast": { "success": { "uploadedImage": "Successfully uploaded image.", - "addFaceLibrary": "Successfully add face library.", + "addFaceLibrary": "{{name}} has successfully been added to the Face Library!", "deletedFace": "Successfully deleted face.", "trainedFace": "Successfully trained face.", "updatedFaceScore": "Successfully updated face score." diff --git a/web/src/components/indicators/StepIndicator.tsx b/web/src/components/indicators/StepIndicator.tsx new file mode 100644 index 000000000..641ae32ca --- /dev/null +++ b/web/src/components/indicators/StepIndicator.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils"; + +type StepIndicatorProps = { + steps: string[]; + currentStep: number; +}; +export default function StepIndicator({ + steps, + currentStep, +}: StepIndicatorProps) { + return ( +
+ {steps.map((name, idx) => ( +
+
+ {idx + 1} +
+
{name}
+
+ ))} +
+ ); +} diff --git a/web/src/components/input/ImageEntry.tsx b/web/src/components/input/ImageEntry.tsx new file mode 100644 index 000000000..afb399177 --- /dev/null +++ b/web/src/components/input/ImageEntry.tsx @@ -0,0 +1,58 @@ +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { useCallback } from "react"; +import { useForm } from "react-hook-form"; + +import { z } from "zod"; + +type ImageEntryProps = { + onSave: (file: File) => void; + children?: React.ReactNode; +}; +export default function ImageEntry({ onSave, children }: ImageEntryProps) { + const formSchema = z.object({ + file: z.instanceof(FileList, { message: "Please select an image file." }), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + const fileRef = form.register("file"); + + // upload handler + + const onSubmit = useCallback( + (data: z.infer) => { + if (!data["file"] || Object.keys(data.file).length == 0) { + return; + } + + onSave(data["file"]["0"]); + }, + [onSave], + ); + + return ( +
+ + ( + + + + + + )} + /> + {children} + + + ); +} diff --git a/web/src/components/input/TextEntry.tsx b/web/src/components/input/TextEntry.tsx new file mode 100644 index 000000000..c9fa8a8a9 --- /dev/null +++ b/web/src/components/input/TextEntry.tsx @@ -0,0 +1,68 @@ +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { useCallback } from "react"; +import { useForm } from "react-hook-form"; + +import { z } from "zod"; + +type TextEntryProps = { + defaultValue?: string; + placeholder?: string; + allowEmpty?: boolean; + onSave: (text: string) => void; + children?: React.ReactNode; +}; +export default function TextEntry({ + defaultValue, + placeholder, + allowEmpty, + onSave, + children, +}: TextEntryProps) { + const formSchema = z.object({ + text: z.string(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { text: defaultValue }, + }); + const fileRef = form.register("text"); + + // upload handler + + const onSubmit = useCallback( + (data: z.infer) => { + if (!allowEmpty && !data["text"]) { + return; + } + onSave(data["text"]); + }, + [onSave, allowEmpty], + ); + + return ( +
+ + ( + + + + + + )} + /> + {children} + + + ); +} diff --git a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx new file mode 100644 index 000000000..659ac4c88 --- /dev/null +++ b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx @@ -0,0 +1,168 @@ +import StepIndicator from "@/components/indicators/StepIndicator"; +import ImageEntry from "@/components/input/ImageEntry"; +import TextEntry from "@/components/input/TextEntry"; +import { + MobilePage, + MobilePageContent, + MobilePageDescription, + MobilePageHeader, + MobilePageTitle, +} from "@/components/mobile/MobilePage"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import axios from "axios"; +import { useCallback, useState } from "react"; +import { isDesktop } from "react-device-detect"; +import { useTranslation } from "react-i18next"; +import { LuExternalLink } from "react-icons/lu"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; + +const STEPS = ["Enter Face Name", "Upload Face Image", "Next Steps"]; + +type CreateFaceWizardDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + onFinish: () => void; +}; +export default function CreateFaceWizardDialog({ + open, + setOpen, + onFinish, +}: CreateFaceWizardDialogProps) { + const { t } = useTranslation("views/faceLibrary"); + + // wizard + + const [step, setStep] = useState(0); + const [name, setName] = useState(""); + + const handleReset = useCallback(() => { + setStep(0); + setName(""); + setOpen(false); + }, [setOpen]); + + // data handling + + const onUploadImage = useCallback( + (file: File) => { + const formData = new FormData(); + formData.append("file", file); + axios + .post(`faces/${name}/register`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) + .then((resp) => { + if (resp.status == 200) { + setStep(2); + toast.success(t("toast.success.uploadedImage"), { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [name, t], + ); + + // layout + + const Overlay = isDesktop ? Dialog : MobilePage; + const Content = isDesktop ? DialogContent : MobilePageContent; + const Header = isDesktop ? DialogHeader : MobilePageHeader; + const Title = isDesktop ? DialogTitle : MobilePageTitle; + const Description = isDesktop ? DialogDescription : MobilePageDescription; + + return ( + { + if (!open) { + handleReset(); + } + }} + > + +
+ {t("button.addFace")} + {isDesktop && {t("description.addFace")}} +
+ + {step == 0 && ( + { + setName(name); + setStep(1); + }} + > +
+ +
+
+ )} + {step == 1 && ( + +
+ +
+
+ )} + {step == 2 && ( +
+ {t("toast.success.addFaceLibrary", { name })} +

+ {t("createFaceLibrary.nextSteps")} +

+
+ + {t("readTheDocs")} + + +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx index a25c023ea..6fc1f9ad3 100644 --- a/web/src/components/overlay/dialog/TextEntryDialog.tsx +++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx @@ -1,3 +1,4 @@ +import TextEntry from "@/components/input/TextEntry"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -7,15 +8,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback, useEffect } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; - type TextEntryDialogProps = { open: boolean; title: string; @@ -35,35 +29,7 @@ export default function TextEntryDialog({ defaultValue = "", allowEmpty = false, }: TextEntryDialogProps) { - const formSchema = z.object({ - text: z.string(), - }); - - const { t } = useTranslation("components/dialog"); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { text: defaultValue }, - }); - const fileRef = form.register("text"); - - // upload handler - - const onSubmit = useCallback( - (data: z.infer) => { - if (!allowEmpty && !data["text"]) { - return; - } - onSave(data["text"]); - }, - [onSave, allowEmpty], - ); - - useEffect(() => { - if (open) { - form.reset({ text: defaultValue }); - } - }, [open, defaultValue, form]); + const { t } = useTranslation("common"); return ( @@ -72,33 +38,20 @@ export default function TextEntryDialog({ {title} {description && {description}} -
- - ( - - - - - - )} - /> - - - - - - + + + + + +
); diff --git a/web/src/components/overlay/dialog/UploadImageDialog.tsx b/web/src/components/overlay/dialog/UploadImageDialog.tsx index 6a01a7fab..7fab82eea 100644 --- a/web/src/components/overlay/dialog/UploadImageDialog.tsx +++ b/web/src/components/overlay/dialog/UploadImageDialog.tsx @@ -1,3 +1,4 @@ +import ImageEntry from "@/components/input/ImageEntry"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -7,12 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; +import { useTranslation } from "react-i18next"; type UploadImageDialogProps = { open: boolean; @@ -28,27 +24,7 @@ export default function UploadImageDialog({ setOpen, onSave, }: UploadImageDialogProps) { - const formSchema = z.object({ - file: z.instanceof(FileList, { message: "Please select an image file." }), - }); - - const form = useForm>({ - resolver: zodResolver(formSchema), - }); - const fileRef = form.register("file"); - - // upload handler - - const onSubmit = useCallback( - (data: z.infer) => { - if (!data["file"] || Object.keys(data.file).length == 0) { - return; - } - - onSave(data["file"]["0"]); - }, - [onSave], - ); + const { t } = useTranslation("common"); return ( @@ -57,31 +33,14 @@ export default function UploadImageDialog({ {title} {description && {description}} -
- - ( - - - - - - )} - /> - - - - - - + + + + + +
); diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 33fbb69d1..afa196f35 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -1,7 +1,8 @@ import { baseUrl } from "@/api/baseUrl"; +import TimeAgo from "@/components/dynamic/TimeAgo"; import AddFaceIcon from "@/components/icons/AddFaceIcon"; import ActivityIndicator from "@/components/indicators/activity-indicator"; -import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import { Button } from "@/components/ui/button"; import { @@ -25,6 +26,7 @@ import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isDesktop } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { LuImagePlus, LuRefreshCw, LuScanFace, LuTrash2 } from "react-icons/lu"; import { toast } from "sonner"; @@ -115,42 +117,16 @@ export default function FaceLibrary() { [pageToggle, refreshFaces, t], ); - const onAddName = useCallback( - (name: string) => { - axios - .post(`faces/${name}/create`, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) - .then((resp) => { - if (resp.status == 200) { - setAddFace(false); - refreshFaces(); - toast.success(t("toast.success.addFaceLibrary"), { - position: "top-center", - }); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error(t("toast.error.addFaceLibraryFailed", { errorMessage }), { - position: "top-center", - }); - }); - }, - [refreshFaces, t], - ); - // face multiselect const [selectedFaces, setSelectedFaces] = useState([]); const onClickFace = useCallback( - (imageId: string) => { + (imageId: string, ctrl: boolean) => { + if (selectedFaces.length == 0 && !ctrl) { + return; + } + const index = selectedFaces.indexOf(imageId); if (index != -1) { @@ -172,33 +148,42 @@ export default function FaceLibrary() { [selectedFaces, setSelectedFaces], ); - const onDelete = useCallback(() => { - axios - .post(`/faces/train/delete`, { ids: selectedFaces }) - .then((resp) => { - setSelectedFaces([]); + const onDelete = useCallback( + (name: string, ids: string[]) => { + axios + .post(`/faces/${name}/delete`, { ids }) + .then((resp) => { + setSelectedFaces([]); - if (resp.status == 200) { - toast.success(t("toast.success.deletedFace"), { + if (resp.status == 200) { + toast.success(t("toast.success.deletedFace"), { + position: "top-center", + }); + + if (faceImages.length == 1) { + // face has been deleted + setPageToggle(""); + } + + refreshFaces(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), { position: "top-center", }); - refreshFaces(); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), { - position: "top-center", }); - }); - }, [selectedFaces, refreshFaces, t]); + }, + [faceImages, refreshFaces, setPageToggle, t], + ); // keyboard - useKeyboardListener(["a"], (key, modifiers) => { + useKeyboardListener(["a", "Escape"], (key, modifiers) => { if (modifiers.repeat || !modifiers.down) { return; } @@ -209,6 +194,9 @@ export default function FaceLibrary() { setSelectedFaces([...trainImages]); } break; + case "Escape": + setSelectedFaces([]); + break; } }); @@ -228,12 +216,10 @@ export default function FaceLibrary() { onSave={onUploadImage} /> -
@@ -283,21 +269,24 @@ export default function FaceLibrary() { {selectedFaces?.length > 0 ? (
-
) : (
{pageToggle != "train" && ( )}
@@ -317,7 +306,7 @@ export default function FaceLibrary() { ))}
@@ -329,7 +318,7 @@ type TrainingGridProps = { attemptImages: string[]; faceNames: string[]; selectedFaces: string[]; - onClickFace: (image: string) => void; + onClickFace: (image: string, ctrl: boolean) => void; onRefresh: () => void; }; function TrainingGrid({ @@ -349,7 +338,7 @@ function TrainingGrid({ faceNames={faceNames} threshold={config.face_recognition.recognition_threshold} selected={selectedFaces.includes(image)} - onClick={() => onClickFace(image)} + onClick={(meta) => onClickFace(image, meta)} onRefresh={onRefresh} /> ))} @@ -362,7 +351,7 @@ type FaceAttemptProps = { faceNames: string[]; threshold: number; selected: boolean; - onClick: () => void; + onClick: (meta: boolean) => void; onRefresh: () => void; }; function FaceAttempt({ @@ -378,6 +367,7 @@ function FaceAttempt({ const parts = image.split("-"); return { + timestamp: Number.parseFloat(parts[0]), eventId: `${parts[0]}-${parts[1]}`, name: parts[2], score: parts[3], @@ -439,10 +429,13 @@ function FaceAttempt({ ? "shadow-selected outline-selected" : "outline-transparent duration-500", )} - onClick={onClick} + onClick={(e) => onClick(e.metaKey || e.ctrlKey)} > -
- +
+ +
+ +
@@ -500,9 +493,9 @@ function FaceAttempt({ type FaceGridProps = { faceImages: string[]; pageToggle: string; - onRefresh: () => void; + onDelete: (name: string, ids: string[]) => void; }; -function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) { +function FaceGrid({ faceImages, pageToggle, onDelete }: FaceGridProps) { return (
{faceImages.map((image: string) => ( @@ -510,7 +503,7 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) { key={image} name={pageToggle} image={image} - onRefresh={onRefresh} + onDelete={onDelete} /> ))}
@@ -520,31 +513,10 @@ function FaceGrid({ faceImages, pageToggle, onRefresh }: FaceGridProps) { type FaceImageProps = { name: string; image: string; - onRefresh: () => void; + onDelete: (name: string, ids: string[]) => void; }; -function FaceImage({ name, image, onRefresh }: FaceImageProps) { +function FaceImage({ name, image, onDelete }: FaceImageProps) { const { t } = useTranslation(["views/faceLibrary"]); - const onDelete = useCallback(() => { - axios - .post(`/faces/${name}/delete`, { ids: [image] }) - .then((resp) => { - if (resp.status == 200) { - toast.success(t("toast.success.deletedFace"), { - position: "top-center", - }); - onRefresh(); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), { - position: "top-center", - }); - }); - }, [name, image, onRefresh, t]); return (
@@ -561,7 +533,7 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) { onDelete(name, [image])} /> {t("button.deleteFaceAttempts")} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index d79a7c953..8cf87f206 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,20 +1,24 @@ import { UserAuthForm } from "@/components/auth/AuthForm"; import Logo from "@/components/Logo"; import { ThemeProvider } from "@/context/theme-provider"; +import "@/utils/i18n"; +import { LanguageProvider } from "@/context/language-provider"; function LoginPage() { return ( -
-
-
-
- + +
+
+
+
+ +
+
-
-
+ ); } From bf22d89f67d68f1b10dab94c472b62509ff5fede Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 17 Mar 2025 15:57:46 -0600 Subject: [PATCH 6/7] Improve Face Library Management (#17213) * Set maximum number of face images to be kept * Fix vertical camera scaling * adjust wording * Add attributes to search data * Add button to train face from event * Handle event id saving in API --- frigate/api/classification.py | 57 ++++++++-- frigate/api/event.py | 1 + frigate/data_processing/real_time/face.py | 11 ++ frigate/util/path.py | 8 ++ web/public/locales/en/views/faceLibrary.json | 2 +- .../overlay/detail/SearchDetailDialog.tsx | 107 +++++++++++++++--- web/src/pages/FaceLibrary.tsx | 2 +- web/src/types/search.ts | 1 + web/src/views/settings/MotionTunerView.tsx | 2 +- web/src/views/settings/ObjectSettingsView.tsx | 2 +- 10 files changed, 167 insertions(+), 26 deletions(-) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 85b604379..df804f34a 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -6,6 +6,7 @@ import random import shutil import string +import cv2 from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import JSONResponse from pathvalidate import sanitize_filename @@ -14,9 +15,11 @@ from playhouse.shortcuts import model_to_dict from frigate.api.auth import require_role from frigate.api.defs.tags import Tags +from frigate.config.camera import DetectConfig from frigate.const import FACE_DIR from frigate.embeddings import EmbeddingsContext from frigate.models import Event +from frigate.util.path import get_event_snapshot logger = logging.getLogger(__name__) @@ -87,16 +90,27 @@ def train_face(request: Request, name: str, body: dict = None): ) json: dict[str, any] = body or {} - training_file = os.path.join( - FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}" - ) + training_file_name = sanitize_filename(json.get("training_file", "")) + training_file = os.path.join(FACE_DIR, f"train/{training_file_name}") + event_id = json.get("event_id") - if not training_file or not os.path.isfile(training_file): + if not training_file_name and not event_id: return JSONResponse( content=( { "success": False, - "message": f"Invalid filename or no file exists: {training_file}", + "message": "A training file or event_id must be passed.", + } + ), + status_code=400, + ) + + if training_file_name and not os.path.isfile(training_file): + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid filename or no file exists: {training_file_name}", } ), status_code=404, @@ -106,7 +120,36 @@ def train_face(request: Request, name: str, body: dict = None): rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) new_name = f"{sanitized_name}-{rand_id}.webp" new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}") - shutil.move(training_file, new_file) + + if training_file_name: + shutil.move(training_file, new_file) + else: + try: + event: Event = Event.get(Event.id == event_id) + except DoesNotExist: + return JSONResponse( + content=( + { + "success": False, + "message": f"Invalid event_id or no event exists: {event_id}", + } + ), + status_code=404, + ) + + snapshot = get_event_snapshot(event) + face_box = event.data["attributes"][0]["box"] + detect_config: DetectConfig = request.app.frigate_config.cameras[ + event.camera + ].detect + + # crop onto the face box minus the bounding box itself + x1 = int(face_box[0] * detect_config.width) + 2 + y1 = int(face_box[1] * detect_config.height) + 2 + x2 = x1 + int(face_box[2] * detect_config.width) - 4 + y2 = y1 + int(face_box[3] * detect_config.height) - 4 + face = snapshot[y1:y2, x1:x2] + cv2.imwrite(new_file, face) context: EmbeddingsContext = request.app.embeddings context.clear_face_classifier() @@ -115,7 +158,7 @@ def train_face(request: Request, name: str, body: dict = None): content=( { "success": True, - "message": f"Successfully saved {training_file} as {new_name}.", + "message": f"Successfully saved {training_file_name} as {new_name}.", } ), status_code=200, diff --git a/frigate/api/event.py b/frigate/api/event.py index 88a865318..c4c763bf7 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -701,6 +701,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) for k, v in event["data"].items() if k in [ + "attributes", "type", "score", "top_score", diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index b51b7a20f..acb891449 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -28,6 +28,7 @@ logger = logging.getLogger(__name__) MAX_DETECTION_HEIGHT = 1080 +MAX_FACE_ATTEMPTS = 100 MIN_MATCHING_FACES = 2 @@ -482,6 +483,16 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): ) shutil.move(current_file, new_file) + files = sorted( + os.listdir(folder), + key=lambda f: os.path.getctime(os.path.join(folder, f)), + reverse=True, + ) + + # delete oldest face image if maximum is reached + if len(files) > MAX_FACE_ATTEMPTS: + os.unlink(os.path.join(folder, files[-1])) + def expire_object(self, object_id: str): if object_id in self.detected_faces: self.detected_faces.pop(object_id) diff --git a/frigate/util/path.py b/frigate/util/path.py index dbe51abe5..565f5a357 100644 --- a/frigate/util/path.py +++ b/frigate/util/path.py @@ -4,6 +4,9 @@ import base64 import os from pathlib import Path +import cv2 +from numpy import ndarray + from frigate.const import CLIPS_DIR, THUMB_DIR from frigate.models import Event @@ -21,6 +24,11 @@ def get_event_thumbnail_bytes(event: Event) -> bytes | None: return None +def get_event_snapshot(event: Event) -> ndarray: + media_name = f"{event.camera}-{event.id}" + return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") + + ### Deletion diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 4028690e3..46842b7ea 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -25,7 +25,7 @@ }, "readTheDocs": "Read the documentation to view more details on refining images for the Face Library", "trainFaceAs": "Train Face as:", - "trainFaceAsPerson": "Train Face as Person", + "trainFace": "Train Face", "toast": { "success": { "uploadedImage": "Successfully uploaded image.", diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 891ce88b1..b22eb9a4c 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -57,6 +57,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; @@ -69,11 +70,12 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { LuInfo } from "react-icons/lu"; +import { LuInfo, LuSearch } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import { useTranslation } from "react-i18next"; +import { TbFaceId } from "react-icons/tb"; const SEARCH_TABS = [ "details", @@ -99,7 +101,7 @@ export default function SearchDetailDialog({ setSimilarity, setInputFocused, }: SearchDetailDialogProps) { - const { t } = useTranslation(["views/explore"]); + const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -555,6 +557,48 @@ function ObjectDetailsTab({ [search, apiHost, mutate, setSearch, t], ); + // face training + + const hasFace = useMemo(() => { + if (!config?.face_recognition.enabled || !search) { + return false; + } + + return search.data.attributes?.find((attr) => attr.label == "face"); + }, [config, search]); + + const { data: faceData } = useSWR(hasFace ? "faces" : null); + + const faceNames = useMemo( + () => + faceData ? Object.keys(faceData).filter((face) => face != "train") : [], + [faceData], + ); + + const onTrainFace = useCallback( + (trainName: string) => { + axios + .post(`/faces/train/${trainName}/classify`, { event_id: search.id }) + .then((resp) => { + if (resp.status == 200) { + toast.success(t("toast.success.trainedFace"), { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.trainFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [search, t], + ); + return (
@@ -673,20 +717,53 @@ function ObjectDetailsTab({ draggable={false} src={`${apiHost}api/events/${search.id}/thumbnail.webp`} /> - {config?.semantic_search.enabled && search.data.type == "object" && ( - - )} + if (setSimilarity) { + setSimilarity(); + } + }} + > +
+ + {t("itemMenu.findSimilar.label")} +
+ + )} + {hasFace && ( + + + + + + + {t("trainFaceAs", { ns: "views/faceLibrary" })} + + {faceNames.map((faceName) => ( + onTrainFace(faceName)} + > + {faceName} + + ))} + + + )} +
diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index afa196f35..94a7f6947 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -472,7 +472,7 @@ function FaceAttempt({ ))} - {t("trainFaceAsPerson")} + {t("trainFace")} diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 5dca11973..2a57385f7 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -50,6 +50,7 @@ export type SearchResult = { score: number; sub_label_score?: number; region: number[]; + attributes?: [{ box: number[]; label: string; score: number }]; box: number[]; area: number; ratio: number; diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx index d1027a14d..98169b4f8 100644 --- a/web/src/views/settings/MotionTunerView.tsx +++ b/web/src/views/settings/MotionTunerView.tsx @@ -323,7 +323,7 @@ export default function MotionTunerView({
{cameraConfig ? ( -
+
{cameraConfig ? ( -
+
Date: Mon, 17 Mar 2025 23:01:40 -0400 Subject: [PATCH 7/7] Fix key error when model path key doesn't exist. (#17217) * fixed metrics race condition * ruff formatting * adjust for default path config * ruff * check for model too --- frigate/api/app.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 9d7b3768f..f19070a3a 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -177,14 +177,18 @@ def config(request: Request): # Add model plus data if plus is enabled if config["plus"]["enabled"]: - model_json_path = FilePath(config["model"]["path"]).with_suffix(".json") - try: - with open(model_json_path, "r") as f: - model_plus_data = json.load(f) - config["model"]["plus"] = model_plus_data - except FileNotFoundError: - config["model"]["plus"] = None - except json.JSONDecodeError: + model_path = config.get("model", {}).get("path") + if model_path: + model_json_path = FilePath(model_path).with_suffix(".json") + try: + with open(model_json_path, "r") as f: + model_plus_data = json.load(f) + config["model"]["plus"] = model_plus_data + except FileNotFoundError: + config["model"]["plus"] = None + except json.JSONDecodeError: + config["model"]["plus"] = None + else: config["model"]["plus"] = None # use merged labelamp