safesight-control/internal/service/tuning.go

346 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// TuningItem defines one adjustable parameter in a template.
type TuningItem struct {
Path string `json:"path"`
Label string `json:"label"`
Type string `json:"type"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Step float64 `json:"step"`
Unit string `json:"unit,omitempty"`
Group string `json:"group,omitempty"`
Hint string `json:"hint,omitempty"`
Desc string `json:"desc,omitempty"`
Value float64 `json:"value"`
}
// GetTuningItems looks up hardcoded tuning definitions for the template and
// resolves current values from the template's actual node parameters.
func (s *ConfigPreviewService) GetTuningItems(templateName string) ([]TuningItem, error) {
stdName := "std_" + templateName
defs := tuningDefinitions[stdName]
if len(defs) == 0 {
return nil, nil
}
raw, _, err := s.readAssetJSON("templates", templateName)
if err != nil {
return nil, err
}
// Deep copy defs and resolve current values from template.
items := make([]TuningItem, len(defs))
copy(items, defs)
templateBody, _ := raw["template"].(map[string]any)
nodes, _ := templateBody["nodes"].([]any)
nodeMap := make(map[string]map[string]any, len(nodes))
for _, n := range nodes {
nm, _ := n.(map[string]any)
if nm != nil {
nodeMap[stringValue(nm["id"])] = nm
}
}
for i := range items {
nodeID, path := splitTuningPath(items[i].Path)
if node, ok := nodeMap[nodeID]; ok {
if v, ok := resolveTuningPath(node, path); ok {
items[i].Value = v
}
}
}
return items, nil
}
// SaveTuningItems writes tuning values directly into the template's node
// parameters and saves the template. Returns affected device IDs.
func (s *ConfigPreviewService) SaveTuningItems(templateName string, values map[string]float64) ([]string, error) {
stdName := "std_" + templateName
defs := tuningDefinitions[stdName]
if len(defs) == 0 {
return nil, fmt.Errorf("该模板没有可调参数")
}
raw, _, err := s.readAssetJSON("templates", templateName)
if err != nil {
return nil, err
}
// Resolve current values from defs, overriding with form values.
items := make([]TuningItem, len(defs))
copy(items, defs)
for i := range items {
if v, ok := values[items[i].Path]; ok {
items[i].Value = v
}
}
// Write values into template's node parameters.
templateBody, _ := raw["template"].(map[string]any)
if templateBody == nil {
return nil, fmt.Errorf("template body not found")
}
nodes, _ := templateBody["nodes"].([]any)
if nodes == nil {
return nil, fmt.Errorf("template nodes not found")
}
nodeMap := make(map[string]map[string]any, len(nodes))
for _, n := range nodes {
nm, _ := n.(map[string]any)
if nm != nil {
nodeMap[stringValue(nm["id"])] = nm
}
}
for _, item := range items {
nodeID, path := splitTuningPath(item.Path)
node, ok := nodeMap[nodeID]
if !ok {
return nil, fmt.Errorf("node %q not found for %s", nodeID, item.Path)
}
if err := setTuningPath(node, path, item.Value); err != nil {
return nil, fmt.Errorf("%s: %w", item.Path, err)
}
}
// Save the modified template.
desc := stringValue(raw["description"])
body, err := marshalConfigJSON(raw)
if err != nil {
return nil, err
}
_ = body
if err := s.SaveTemplateAsset(templateName, desc, string(body)); err != nil {
return nil, fmt.Errorf("保存模板失败: %w", err)
}
return s.GetDevicesUsingTemplate(templateName)
}
// GetDevicesUsingTemplate returns device IDs that use the given template.
func (s *ConfigPreviewService) GetDevicesUsingTemplate(templateName string) ([]string, error) {
if s == nil || s.assets == nil {
return nil, nil
}
profileNames, err := s.profileNamesReferencingTemplate(templateName)
if err != nil {
return nil, err
}
assignments, err := s.ListDeviceAssignments()
if err != nil {
return nil, err
}
deviceSet := map[string]bool{}
for _, a := range assignments {
for _, pn := range profileNames {
if a.ProfileName == pn {
deviceSet[a.DeviceID] = true
break
}
}
}
devices := make([]string, 0, len(deviceSet))
for id := range deviceSet {
devices = append(devices, id)
}
sort.Strings(devices)
return devices, nil
}
// RedeployDevices re-renders config for each device (using existing assignments)
// and creates deploy tasks. Returns task IDs.
func (s *ConfigPreviewService) RedeployDevices(deviceIDs []string) ([]string, error) {
if s == nil {
return nil, nil
}
// The actual deployment is done by the caller (AutoConfigService or UI handler)
// because it needs access to AutoConfigService.BuildPipeline.
// For now, just validate the device IDs exist.
return deviceIDs, nil
}
// --- path parsing helpers ---
// tuningDefinitions maps standard template names to their adjustable parameters.
// To expose a new parameter: add a TuningItem entry below.
var tuningDefinitions = map[string][]TuningItem{
"std_workshop_face_recognition_shoe_alarm": {
// ── 劳保鞋违规告警 ──
{Path: "alarm_violation.rules[0].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 5000, Max: 300000, Step: 1000, Unit: "ms", Group: "劳保鞋违规", Hint: "测试 15s生产 60s", Desc: "同一违规两次告警的最短间隔,值越大告警越少"},
{Path: "alarm_violation.rules[0].min_duration_ms", Label: "最小持续时间", Type: "slider", Min: 200, Max: 10000, Step: 100, Unit: "ms", Group: "劳保鞋违规", Hint: "测试 800ms生产 1500ms", Desc: "违规需持续多久才触发告警,值越大越不敏感"},
{Path: "alarm_violation.rules[0].min_hits", Label: "最小命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "劳保鞋违规", Hint: "测试 2生产 3", Desc: "连续检测到多少次才确认违规,值越大误报越少"},
{Path: "alarm_violation.rules[0].min_score", Label: "最低置信度", Type: "slider", Min: 0.1, Max: 0.9, Step: 0.05, Group: "劳保鞋违规", Hint: "测试 0.3,生产 0.5", Desc: "检测结果的可信度门槛,值越高告警越准确但可能漏报"},
// ── 陌生人脸告警 ──
{Path: "alarm_violation.face_rules[0].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 2000, Max: 120000, Step: 1000, Unit: "ms", Group: "陌生人脸", Hint: "测试 7s生产 30s", Desc: "同一陌生人重复告警的最短间隔,值越大告警越少"},
{Path: "alarm_violation.face_rules[0].min_hits", Label: "命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "陌生人脸", Hint: "测试 2生产 4", Desc: "连续识别为陌生人的帧数,值越大误报越少但响应变慢"},
{Path: "alarm_violation.face_rules[0].max_known_sim", Label: "相似度上限", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "陌生人脸", Hint: "测试 0.35,生产 0.2", Desc: "相似度低于此值才判定为陌生人,值越低陌生人越少"},
{Path: "alarm_violation.face_rules[0].hit_window_ms", Label: "命中窗口", Type: "slider", Min: 500, Max: 10000, Step: 500, Unit: "ms", Group: "陌生人脸", Hint: "测试 1500ms生产 3000ms", Desc: "累计命中帧数的时间窗口,窗口越大越容易触发告警"},
// ── 已知人员告警 ──
{Path: "alarm_violation.face_rules[1].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 2000, Max: 120000, Step: 1000, Unit: "ms", Group: "已知人员", Hint: "测试 7s生产 30s", Desc: "同一已知人员重复告警的最短间隔,值越大告警越少"},
{Path: "alarm_violation.face_rules[1].min_sim", Label: "相似度阈值", Type: "slider", Min: 0.3, Max: 0.95, Step: 0.05, Group: "已知人员", Hint: "测试 0.6,生产 0.75", Desc: "相似度超过此值才认作已知人员,值越高匹配越严格"},
{Path: "alarm_violation.face_rules[1].min_hits", Label: "命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "已知人员", Hint: "测试 2生产 3", Desc: "连续识别为同一人的帧数,值越大越可靠但识别变慢"},
{Path: "alarm_violation.face_rules[1].hit_window_ms", Label: "命中窗口", Type: "slider", Min: 500, Max: 10000, Step: 500, Unit: "ms", Group: "已知人员", Hint: "测试 1500ms生产 2000ms", Desc: "累计命中帧数的时间窗口,窗口越大越容易触发告警"},
// ── 人脸识别 ──
{Path: "recognize_face.threshold.accept", Label: "接受阈值", Type: "slider", Min: 0.3, Max: 0.9, Step: 0.05, Group: "人脸识别", Hint: "默认 0.45", Desc: "人脸特征匹配的全局门槛,值越高误识别越少但漏识别增多"},
{Path: "detect_face.conf_thresh", Label: "检测置信度", Type: "slider", Min: 0.2, Max: 0.9, Step: 0.05, Group: "人脸识别", Hint: "默认 0.5", Desc: "检测到人脸的可信度门槛,值越高误检越少"},
// ── 工鞋检测 ──
{Path: "detect_shoe.conf", Label: "检测置信度", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "工鞋检测", Hint: "默认 0.22", Desc: "工鞋检测模型的可信度门槛,值越高检测越严格"},
{Path: "rule_shoe_association.person_shoe_check.min_shoe_score", Label: "工鞋最低分数", Type: "slider", Min: 0.1, Max: 0.6, Step: 0.02, Group: "工鞋检测", Hint: "默认 0.22", Desc: "工鞋检测结果的最低可信度,值越高误检越少"},
{Path: "rule_shoe_association.person_shoe_check.min_person_score", Label: "人员最低分数", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "工鞋检测", Hint: "默认 0.3", Desc: "人员检测结果的最低可信度,值越高只对清晰人体做鞋检测"},
// ── 人体检测 ──
{Path: "detect_person.conf", Label: "检测置信度", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "人体检测", Hint: "默认 0.35", Desc: "检测到人体的可信度门槛,值越高误检越少但可能漏人"},
},
}
func splitTuningPath(fullPath string) (nodeID string, rest string) {
dot := strings.IndexByte(fullPath, '.')
if dot < 0 {
return fullPath, ""
}
return fullPath[:dot], fullPath[dot+1:]
}
func resolveTuningPath(root map[string]any, path string) (float64, bool) {
if path == "" {
return 0, false
}
seg := path
rest := ""
if dot := strings.IndexByte(path, '.'); dot >= 0 {
seg = path[:dot]
rest = path[dot+1:]
}
bracket := strings.IndexByte(seg, '[')
if bracket >= 0 {
name := seg[:bracket]
idxStr := seg[bracket+1:]
if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' {
idxStr = idxStr[:len(idxStr)-1]
}
idx, err := strconv.Atoi(idxStr)
if err != nil {
return 0, false
}
arr, _ := root[name].([]any)
if idx < 0 || idx >= len(arr) {
return 0, false
}
if rest == "" {
v, _ := toFloat(arr[idx])
return v, true
}
cm, _ := arr[idx].(map[string]any)
if cm == nil {
return 0, false
}
return resolveTuningPath(cm, rest)
}
val, ok := root[seg]
if !ok {
return 0, false
}
if rest == "" {
v, ok := toFloat(val)
return v, ok
}
cm, _ := val.(map[string]any)
if cm == nil {
return 0, false
}
return resolveTuningPath(cm, rest)
}
func setTuningPath(root map[string]any, path string, value float64) error {
if path == "" {
return fmt.Errorf("empty path")
}
seg := path
rest := ""
if dot := strings.IndexByte(path, '.'); dot >= 0 {
seg = path[:dot]
rest = path[dot+1:]
}
bracket := strings.IndexByte(seg, '[')
if bracket >= 0 {
name := seg[:bracket]
idxStr := seg[bracket+1:]
if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' {
idxStr = idxStr[:len(idxStr)-1]
}
idx, err := strconv.Atoi(idxStr)
if err != nil {
return fmt.Errorf("invalid array index: %s", idxStr)
}
arr, _ := root[name].([]any)
if idx < 0 || idx >= len(arr) {
return fmt.Errorf("index %d out of range", idx)
}
if rest == "" {
arr[idx] = value
return nil
}
cm, _ := arr[idx].(map[string]any)
if cm == nil {
cm = map[string]any{}
arr[idx] = cm
}
return setTuningPath(cm, rest, value)
}
if rest == "" {
root[seg] = value
return nil
}
cm, _ := root[seg].(map[string]any)
if cm == nil {
cm = map[string]any{}
root[seg] = cm
}
return setTuningPath(cm, rest, value)
}
func floatValue(m map[string]any, key string, defaultVal float64) float64 {
if v, ok := m[key]; ok {
if f, ok := toFloat(v); ok {
return f
}
}
return defaultVal
}
func toFloat(v any) (float64, bool) {
switch vv := v.(type) {
case float64:
return vv, true
case float32:
return float64(vv), true
case int:
return float64(vv), true
case int64:
return float64(vv), true
case json.Number:
f, err := vv.Float64()
return f, err == nil
default:
return 0, false
}
}