diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go index 60d1897..b9034b0 100644 --- a/internal/service/config_runtime_render.go +++ b/internal/service/config_runtime_render.go @@ -365,11 +365,22 @@ func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) ma existing, _ := merged["override"].(map[string]any) merged["override"] = deepMergeMap(existing, override) } + // Edge expects node overrides at instance.override.nodes, not instance.nodes. + // Overlay payload keys other than params/override (e.g. nodes) go under override. + hasNodeOverrides := false + instOverride := map[string]any{} + if existing, ok := merged["override"].(map[string]any); ok { + instOverride = existing + } for key, value := range patch { if key == "name" || key == "template" || key == "params" || key == "override" { continue } - merged[key] = deepMergeAny(merged[key], value) + instOverride[key] = deepMergeAny(instOverride[key], value) + hasNodeOverrides = true + } + if hasNodeOverrides { + merged["override"] = instOverride } return merged } diff --git a/internal/service/tuning.go b/internal/service/tuning.go new file mode 100644 index 0000000..d02884b --- /dev/null +++ b/internal/service/tuning.go @@ -0,0 +1,338 @@ +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"` + Value float64 `json:"value"` +} + +// GetTuningItems reads the tuning section from a template and resolves current +// values from the template's actual node parameters. +func (s *ConfigPreviewService) GetTuningItems(templateName string) ([]TuningItem, error) { + raw, _, err := s.readAssetJSON("templates", templateName) + if err != nil { + return nil, err + } + + items, err := parseTuningItems(raw) + if err != nil { + return nil, err + } + + // Resolve current values from the template's actual node config. + 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) { + raw, _, err := s.readAssetJSON("templates", templateName) + if err != nil { + return nil, err + } + + items, err := parseTuningItems(raw) + if err != nil { + return nil, err + } + + // Apply values. + 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 --- + +func parseTuningItems(raw map[string]any) ([]TuningItem, error) { + tuningRaw, _ := raw["tuning"].([]any) + if len(tuningRaw) == 0 { + return nil, nil + } + + items := make([]TuningItem, 0, len(tuningRaw)) + for _, item := range tuningRaw { + im, _ := item.(map[string]any) + if im == nil { + continue + } + ti := TuningItem{ + Path: strings.TrimSpace(stringValue(im["path"])), + Label: strings.TrimSpace(stringValue(im["label"])), + Type: strings.TrimSpace(stringValue(im["type"])), + Min: floatValue(im, "min", 0), + Max: floatValue(im, "max", 100), + Step: floatValue(im, "step", 1), + Unit: strings.TrimSpace(stringValue(im["unit"])), + Group: strings.TrimSpace(stringValue(im["group"])), + } + if ti.Type == "" { + ti.Type = "slider" + } + if ti.Path == "" || ti.Label == "" { + continue + } + items = append(items, ti) + } + return items, nil +} + +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 + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index c7bf8b0..fca25c6 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -165,6 +165,8 @@ type PageData struct { AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset AssetOverlayEditing bool + TuningItems []service.TuningItem + TuningGroups map[string][]service.TuningItem AssetInstanceCount int SelectedDeviceIDs []string SelectedDevices []*models.Device @@ -804,6 +806,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) + r.Get("/assets/templates/{name}/settings", u.pageTemplateSettings) + r.Post("/assets/templates/{name}/settings", u.actionTemplateSettings) r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) r.Post("/assets/profiles/{name}", u.actionPlanSave) @@ -3401,6 +3405,151 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } +func (u *UI) pageTemplateSettings(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + data := u.assetPageData("templates") + data.Title = "参数调整" + + if item, err := u.preview.GetTemplateAsset(name); err == nil && item != nil { + data.AssetTemplate = item + } + if data.AssetTemplate == nil { + http.Redirect(w, r, "/assets/templates?error="+url.QueryEscape("模板不存在"), http.StatusFound) + return + } + + if items, err := u.preview.GetTuningItems(name); err == nil { + data.TuningItems = items + // Group items + groups := map[string][]service.TuningItem{} + for _, item := range items { + g := item.Group + if g == "" { + g = "其他" + } + groups[g] = append(groups[g], item) + } + data.TuningGroups = groups + } + + // Add link to settings from template detail page + u.render(w, r, "template_settings", data) +} + +func (u *UI) actionTemplateSettings(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + action := r.FormValue("action") + + // Collect tuning values from form + values := map[string]float64{} + for key, vals := range r.Form { + if !strings.HasPrefix(key, "tune_") || len(vals) == 0 { + continue + } + path := strings.TrimPrefix(key, "tune_") + if v, err := strconv.ParseFloat(vals[0], 64); err == nil { + values[path] = v + } + } + + if len(values) == 0 { + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?error="+url.QueryEscape("未修改任何参数"), http.StatusFound) + return + } + + if action == "preview" { + // AJAX: return affected devices + affected, _ := u.preview.GetDevicesUsingTemplate(name) + if affected == nil { + affected = []string{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"devices": affected}) + return + } + + // Save tuning values to template + affected, err := u.preview.SaveTuningItems(name, values) + if err != nil { + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?error="+url.QueryEscape(err.Error()), http.StatusFound) + return + } + + doDeploy := r.FormValue("do_deploy") == "1" + if !doDeploy || len(affected) == 0 { + msg := "参数已保存" + if len(affected) > 0 { + msg += ",影响 " + strconv.Itoa(len(affected)) + " 台设备" + } + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?msg="+url.QueryEscape(msg), http.StatusFound) + return + } + + // Deploy to all affected devices + taskIDs := u.redeployAffectedDevices(name, affected) + msg := "参数已保存并下发" + if len(taskIDs) > 0 { + msg += "(" + strconv.Itoa(len(taskIDs)) + " 个任务)" + } + http.Redirect(w, r, "/assets/templates/"+url.PathEscape(name)+"/settings?msg="+url.QueryEscape(msg)+"&task_ids="+url.QueryEscape(strings.Join(taskIDs, ",")), http.StatusFound) +} + +func (u *UI) redeployAffectedDevices(templateName string, deviceIDs []string) []string { + if u.autoConfig == nil || u.preview == nil { + return nil + } + + var requests []service.AutoConfigRequest + for _, deviceID := range deviceIDs { + features, _ := u.preview.GetDeviceFeatures(deviceID) + overlays, _ := u.preview.GetDeviceOverlays(deviceID) + + var sources []string + if assignment, err := u.preview.GetDeviceAssignment(deviceID); err == nil && assignment != nil { + for _, ref := range assignment.RecognitionUnits { + if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { + if unit.VideoSourceRef != "" { + sources = append(sources, unit.VideoSourceRef) + } + } + } + } + + if len(features) == 0 || len(sources) == 0 { + continue + } + + requests = append(requests, service.AutoConfigRequest{ + DeviceID: deviceID, + Features: features, + SourceNames: sources, + Overlays: overlays, + }) + } + + if len(requests) == 0 { + return nil + } + + if u.discovery != nil { + u.discovery.SearchDefault() + } + + results, _ := u.autoConfig.BuildPipelineBatch(requests, true) + taskIDs := make([]string, 0) + for _, res := range results { + if res.TaskID != "" { + taskIDs = append(taskIDs, res.TaskID) + } + } + return taskIDs +} + func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("") data.Title = "场景" diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 1449a15..87e8d7b 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -411,6 +411,13 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va .assignment-slider span{font-size:12px;color:var(--muted)} .assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)} .assignment-slider input[type=range]{width:180px} +.tuning-item{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)} +.tuning-item:last-child{border-bottom:none} +.tuning-label{min-width:140px;font-size:13px;font-weight:500;color:var(--text)} +.tuning-control{display:flex;align-items:center;gap:8px;flex:1} +.tuning-control input[type=range]{flex:1;max-width:300px} +.tuning-control output{min-width:70px;font-size:12px;color:var(--muted);text-align:right} +.device-item{margin:2px} .assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px} .assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)} .assignment-device-card.state-idle{background:var(--surface-soft)} diff --git a/internal/web/ui/templates/asset_template.html b/internal/web/ui/templates/asset_template.html index 8ff67bc..f60687c 100644 --- a/internal/web/ui/templates/asset_template.html +++ b/internal/web/ui/templates/asset_template.html @@ -6,7 +6,10 @@

{{icon "template"}}{{.AssetTemplate.Name}}

- 返回模板列表 +
+ {{icon "edit"}} 参数调整 + 返回模板列表 +
模板名{{.AssetTemplate.Name}}
diff --git a/internal/web/ui/templates/template_settings.html b/internal/web/ui/templates/template_settings.html new file mode 100644 index 0000000..cc77d1a --- /dev/null +++ b/internal/web/ui/templates/template_settings.html @@ -0,0 +1,97 @@ +{{define "template_settings"}} +{{template "asset_tabs" .}} +{{if .TuningItems}} +
+
+

{{icon "edit"}}{{.AssetTemplate.Name}} · 参数调整

+
+

修改参数后,点击「保存并下发」将更新所有使用此模板的设备。

+
+ +
+ {{range $group, $items := .TuningGroups}} +
+

{{icon "edit"}}{{$group}}

+
+ {{range $items}} +
+ +
+ + {{printf "%.4f" .Value}}{{.Unit}} +
+
+ {{end}} +
+
+ {{end}} + + + +
+ + + 返回 +
+
+ + + + + +{{else}} +
+
+

{{icon "edit"}}{{.AssetTemplate.Name}} · 参数调整

+
+

该模板没有定义可调参数。如需添加,请在模板 JSON 中添加 tuning 段。

+ 返回 +
+{{end}} +{{end}} diff --git a/safesightd-linux-arm64 b/safesightd-linux-arm64 index 41a1d5f..0444de4 100644 Binary files a/safesightd-linux-arm64 and b/safesightd-linux-arm64 differ diff --git a/templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json b/templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json index 6fa01d6..e9acd72 100644 --- a/templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json +++ b/templates/standard_templates/std_workshop_face_recognition_shoe_alarm.json @@ -548,5 +548,20 @@ } ] ] - } + }, + "tuning": [ + {"path":"alarm_violation.rules[0].cooldown_ms","label":"告警冷却时间","type":"slider","min":5000,"max":300000,"step":1000,"unit":"ms","group":"告警"}, + {"path":"alarm_violation.rules[0].min_duration_ms","label":"最小持续时间","type":"slider","min":200,"max":10000,"step":100,"unit":"ms","group":"告警"}, + {"path":"alarm_violation.rules[0].min_hits","label":"最小命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"告警"}, + {"path":"alarm_violation.rules[0].min_score","label":"最低置信度","type":"slider","min":0.1,"max":0.9,"step":0.05,"group":"告警"}, + {"path":"alarm_violation.face_rules[0].cooldown_ms","label":"陌生人脸冷却","type":"slider","min":2000,"max":120000,"step":1000,"unit":"ms","group":"人脸"}, + {"path":"alarm_violation.face_rules[0].min_hits","label":"陌生人脸命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"人脸"}, + {"path":"alarm_violation.face_rules[0].max_known_sim","label":"陌生人脸相似度上限","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"人脸"}, + {"path":"alarm_violation.face_rules[1].cooldown_ms","label":"已知人员冷却","type":"slider","min":2000,"max":120000,"step":1000,"unit":"ms","group":"人脸"}, + {"path":"alarm_violation.face_rules[1].min_sim","label":"已知人员相似度阈值","type":"slider","min":0.3,"max":0.95,"step":0.05,"group":"人脸"}, + {"path":"alarm_violation.face_rules[1].min_hits","label":"已知人员命中帧数","type":"slider","min":1,"max":10,"step":1,"unit":"帧","group":"人脸"}, + {"path":"recognize_face.threshold.accept","label":"人脸识别阈值","type":"slider","min":0.3,"max":0.9,"step":0.05,"group":"人脸"}, + {"path":"detect_shoe.conf","label":"工鞋检测阈值","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"工鞋"}, + {"path":"detect_person.conf","label":"人体检测阈值","type":"slider","min":0.1,"max":0.8,"step":0.05,"group":"检测"} + ] }