346 lines
12 KiB
Go
346 lines
12 KiB
Go
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
|
||
}
|
||
}
|