feat: template tuning - slider-based parameter adjustment with affected-device detection

This commit is contained in:
tian 2026-07-27 18:26:04 +08:00
parent 0895d19fe0
commit b2cb0b9a67
8 changed files with 623 additions and 3 deletions

View File

@ -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
}

338
internal/service/tuning.go Normal file
View File

@ -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
}
}

View File

@ -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 = "场景"

View File

@ -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)}

View File

@ -6,7 +6,10 @@
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
</div>
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
<div class="btn-group">
<a class="btn primary" href="/assets/templates/{{.AssetTemplate.Name}}/settings">{{icon "edit"}} 参数调整</a>
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
</div>
</div>
<div class="info-list">
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>

View File

@ -0,0 +1,97 @@
{{define "template_settings"}}
{{template "asset_tabs" .}}
{{if .TuningItems}}
<div class="card">
<div class="section-title">
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
</div>
<p class="hint">修改参数后,点击「保存并下发」将更新所有使用此模板的设备。</p>
</div>
<form method="POST" action="/assets/templates/{{.AssetTemplate.Name}}/settings">
{{range $group, $items := .TuningGroups}}
<div class="card">
<h3 class="title-with-icon">{{icon "edit"}}<span>{{$group}}</span></h3>
<div class="tuning-list">
{{range $items}}
<div class="tuning-item">
<label class="tuning-label">{{.Label}}</label>
<div class="tuning-control">
<input type="range" name="tune_{{.Path}}" value="{{printf "%.4f" .Value}}" min="{{.Min}}" max="{{.Max}}" step="{{.Step}}"
oninput="this.nextElementSibling.value=Number(this.value).toFixed({{if eq .Step 1.0}}0{{else if eq .Step 0.05}}2{{else}}1{{end}})+'{{.Unit}}'">
<output>{{printf "%.4f" .Value}}{{.Unit}}</output>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<div class="card" id="affected-devices" style="display:none">
<h3 class="title-with-icon">{{icon "device"}}<span>受影响设备 (<span id="affected-count">0</span>)</span></h3>
<div id="affected-list" class="device-list"></div>
</div>
<div class="card actions">
<button type="submit" class="btn primary" name="action" value="save">保存参数</button>
<button type="submit" class="btn primary" name="action" value="deploy" id="btn-deploy" disabled>保存并下发</button>
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
</div>
</form>
<script>
document.querySelector('form').addEventListener('submit', async function(e) {
const btn = e.submitter;
if (!btn || btn.value !== 'deploy') return;
e.preventDefault();
const form = this;
const fd = new FormData(form);
fd.set('action', 'preview');
const resp = await fetch(form.action, { method: 'POST', body: fd });
const data = await resp.json();
const div = document.getElementById('affected-devices');
const count = document.getElementById('affected-count');
const list = document.getElementById('affected-list');
const deployBtn = document.getElementById('btn-deploy');
div.style.display = 'block';
count.textContent = data.devices ? data.devices.length : 0;
if (data.devices && data.devices.length > 0) {
deployBtn.disabled = false;
list.innerHTML = data.devices.map(function(d) {
return '<span class="pill ok device-item">' + d + '</span>';
}).join('');
} else {
deployBtn.disabled = true;
list.innerHTML = '<p class="hint">没有设备使用此模板。请先在运行看板中为设备分配此模板。</p>';
}
// Store affected devices for final submit
document.getElementById('affected-ids').value = (data.devices || []).join(',');
document.getElementById('deploy-flag').value = '1';
});
document.getElementById('btn-deploy').addEventListener('click', function() {
if (document.getElementById('affected-ids').value) {
document.getElementById('deploy-flag').value = '1';
document.querySelector('form').submit();
}
});
</script>
<input type="hidden" name="affected_ids" id="affected-ids" value="">
<input type="hidden" name="do_deploy" id="deploy-flag" value="0">
{{else}}
<div class="card">
<div class="section-title">
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
</div>
<p class="hint">该模板没有定义可调参数。如需添加,请在模板 JSON 中添加 tuning 段。</p>
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
</div>
{{end}}
{{end}}

Binary file not shown.

View File

@ -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":"检测"}
]
}