detached 08d5eeb

feat(hints): use the model's threshold when hint.threshold is unset

commit
08d5eeba7e73280e545643475080f6abac9661bd
author
brice · · 2026-10-22 05:26 UTC
parent
2fd66b7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +48 -15
M docs/config.md +1 -1
@@ -62,7 +62,7 @@ See [examples/config](../examples/config) for every key with its default.
6262 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |
6363 | `hint.detector` | `nefer-hints` | The [hint mode](#hint-mode) detector binary: a path, or a name looked up in `PATH` (restart required) |
6464 | `hint.model` | `$XDG_DATA_HOME/nefer-hints/models` | Directory of the detector's model files; `~/.local/share/nefer-hints/models` when `XDG_DATA_HOME` is unset; a leading `~/` is expanded (restart required) |
65-| `hint.threshold` | `0.05` | Minimum detection score, 0.01 to 0.99; lower finds more targets and more false ones (restart required) |
65+| `hint.threshold` | model default | Optional minimum detection score override, 0.01 to 0.99; unset or empty uses the model’s own threshold (`model.json`), else the runner default. Lower finds more targets and more false ones (restart required) |
6666 | `hint.capture` | `off` | Show hint labels in captures requested by Wayland clients (screenshots, screencasts); the compositor's own hint scans never see them; applies on reload |
6767 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |
6868 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |
@@ -62,7 +62,7 @@ See [examples/config](../examples/config) for every key with its default.
62 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |62 | `hint.enable` | `off` | Turn on the experimental [hint mode](#hint-mode) (restart required) |
63 | `hint.detector` | `nefer-hints` | The [hint mode](#hint-mode) detector binary: a path, or a name looked up in `PATH` (restart required) |63 | `hint.detector` | `nefer-hints` | The [hint mode](#hint-mode) detector binary: a path, or a name looked up in `PATH` (restart required) |
64 | `hint.model` | `$XDG_DATA_HOME/nefer-hints/models` | Directory of the detector's model files; `~/.local/share/nefer-hints/models` when `XDG_DATA_HOME` is unset; a leading `~/` is expanded (restart required) |64 | `hint.model` | `$XDG_DATA_HOME/nefer-hints/models` | Directory of the detector's model files; `~/.local/share/nefer-hints/models` when `XDG_DATA_HOME` is unset; a leading `~/` is expanded (restart required) |
65-| `hint.threshold` | `0.05` | Minimum detection score, 0.01 to 0.99; lower finds more targets and more false ones (restart required) |65+| `hint.threshold` | model default | Optional minimum detection score override, 0.01 to 0.99; unset or empty uses the model’s own threshold (`model.json`), else the runner default. Lower finds more targets and more false ones (restart required) |
66 | `hint.capture` | `off` | Show hint labels in captures requested by Wayland clients (screenshots, screencasts); the compositor's own hint scans never see them; applies on reload |66 | `hint.capture` | `off` | Show hint labels in captures requested by Wayland clients (screenshots, screencasts); the compositor's own hint scans never see them; applies on reload |
67 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |67 | `hint.size` | `auto` | Label text size in logical pixels (6 to 72), or `auto` for screen pixel density |
68 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |68 | `hint.background` | empty | Label background (`#rrggbb`); empty uses `border.active` |
M examples/config +3 -1
@@ -101,7 +101,9 @@ focus.flash-color =
101101 # Hint mode finds its targets with the nefer-hints detector (restart required).
102102 # hint.detector = nefer-hints
103103 # hint.model = ~/.local/share/nefer-hints/models
104-# hint.threshold = 0.05
104+# Unset or empty uses the model threshold (model.json), else the runner default.
105+# Set 0.01..0.99 to override it.
106+# hint.threshold =
105107 # Show the labels in screenshots and screencasts taken by clients.
106108 # hint.capture = off
107109
@@ -101,7 +101,9 @@ focus.flash-color =
101 # Hint mode finds its targets with the nefer-hints detector (restart required).101 # Hint mode finds its targets with the nefer-hints detector (restart required).
102 # hint.detector = nefer-hints102 # hint.detector = nefer-hints
103 # hint.model = ~/.local/share/nefer-hints/models103 # hint.model = ~/.local/share/nefer-hints/models
104-# hint.threshold = 0.05104+# Unset or empty uses the model threshold (model.json), else the runner default.
105+# Set 0.01..0.99 to override it.
106+# hint.threshold =
105 # Show the labels in screenshots and screencasts taken by clients.107 # Show the labels in screenshots and screencasts taken by clients.
106 # hint.capture = off108 # hint.capture = off
107 109
M internal/adapters/config/config.go +5 -5
@@ -168,7 +168,6 @@ func Defaults() ports.Config {
168168 c.Hint.Enable = false
169169 c.Hint.Detector = defaultHintDetector
170170 c.Hint.Model = defaultHintModel()
171- c.Hint.Threshold = defaultHintThreshold
172171 c.Xwayland = "xwayland-satellite"
173172 c.Background.Color = "#111111"
174173 c.Floating.Dim = 0.3
@@ -909,6 +908,10 @@ func set(c *ports.Config, key, v string) error {
909908 }
910909 c.Hint.Model = expandHome(v)
911910 case "hint.threshold":
911+ if v == "" {
912+ c.Hint.Threshold = 0
913+ break
914+ }
912915 n, err := strconv.ParseFloat(v, 64)
913916 if err != nil || math.IsNaN(n) || n < 0.01 || n > 0.99 {
914917 return fmt.Errorf("must be a number from 0.01 to 0.99")
@@ -1337,10 +1340,7 @@ func checkAction(v string) error {
13371340 return nil
13381341 }
13391342
1340-const (
1341- defaultHintDetector = "nefer-hints"
1342- defaultHintThreshold = 0.05
1343-)
1343+const defaultHintDetector = "nefer-hints"
13441344
13451345 // defaultHintModel is $XDG_DATA_HOME/nefer-hints/models, or
13461346 // ~/.local/share/nefer-hints/models when XDG_DATA_HOME is unset.
@@ -168,7 +168,6 @@ func Defaults() ports.Config {
168 c.Hint.Enable = false168 c.Hint.Enable = false
169 c.Hint.Detector = defaultHintDetector169 c.Hint.Detector = defaultHintDetector
170 c.Hint.Model = defaultHintModel()170 c.Hint.Model = defaultHintModel()
171- c.Hint.Threshold = defaultHintThreshold
172 c.Xwayland = "xwayland-satellite"171 c.Xwayland = "xwayland-satellite"
173 c.Background.Color = "#111111"172 c.Background.Color = "#111111"
174 c.Floating.Dim = 0.3173 c.Floating.Dim = 0.3
@@ -909,6 +908,10 @@ func set(c *ports.Config, key, v string) error {
909 }908 }
910 c.Hint.Model = expandHome(v)909 c.Hint.Model = expandHome(v)
911 case "hint.threshold":910 case "hint.threshold":
911+ if v == "" {
912+ c.Hint.Threshold = 0
913+ break
914+ }
912 n, err := strconv.ParseFloat(v, 64)915 n, err := strconv.ParseFloat(v, 64)
913 if err != nil || math.IsNaN(n) || n < 0.01 || n > 0.99 {916 if err != nil || math.IsNaN(n) || n < 0.01 || n > 0.99 {
914 return fmt.Errorf("must be a number from 0.01 to 0.99")917 return fmt.Errorf("must be a number from 0.01 to 0.99")
@@ -1337,10 +1340,7 @@ func checkAction(v string) error {
1337 return nil1340 return nil
1338 }1341 }
1339 1342
1340-const (1343+const defaultHintDetector = "nefer-hints"
1341- defaultHintDetector = "nefer-hints"
1342- defaultHintThreshold = 0.05
1343-)
1344 1344
1345 // defaultHintModel is $XDG_DATA_HOME/nefer-hints/models, or1345 // defaultHintModel is $XDG_DATA_HOME/nefer-hints/models, or
1346 // ~/.local/share/nefer-hints/models when XDG_DATA_HOME is unset.1346 // ~/.local/share/nefer-hints/models when XDG_DATA_HOME is unset.
M internal/adapters/config/config_test.go +14 -4
@@ -974,7 +974,7 @@ func TestHintDetectorConfig(t *testing.T) {
974974 t.Setenv("HOME", "/home/u")
975975 t.Setenv("XDG_DATA_HOME", "")
976976 d := Defaults().Hint
977- if d.Detector != "nefer-hints" || d.Model != "/home/u/.local/share/nefer-hints/models" || d.Threshold != 0.05 {
977+ if d.Detector != "nefer-hints" || d.Model != "/home/u/.local/share/nefer-hints/models" || d.Threshold != 0 {
978978 t.Fatalf("defaults: %+v", d)
979979 }
980980 t.Setenv("XDG_DATA_HOME", "/data")
@@ -989,14 +989,24 @@ func TestHintDetectorConfig(t *testing.T) {
989989 if len(w) != 0 || c.Hint.Detector != "/opt/nh" || c.Hint.Model != "/opt/models" {
990990 t.Fatalf("%+v %v", c.Hint, w)
991991 }
992- for _, v := range []string{"0", "0.009", "1", "0.995", "NaN", "high", "-1"} {
992+ for _, input := range []string{"", "hint.threshold =\n"} {
993+ c, w := parseString(t, input)
994+ if len(w) != 0 || c.Hint.Threshold != 0 {
995+ t.Errorf("unset threshold %q: %v %v", input, c.Hint.Threshold, w)
996+ }
997+ }
998+ c, w = parseString(t, "hint.threshold = 0.3\nhint.threshold =\n")
999+ if len(w) != 1 || c.Hint.Threshold != 0 { // duplicate-key warning only
1000+ t.Fatalf("cleared threshold: %v %v", c.Hint.Threshold, w)
1001+ }
1002+ for _, v := range []string{"0", "0.009", "1", "0.995", "NaN", "Inf", "-Inf", "high", "-1"} {
9931003 c, w := parseString(t, "hint.threshold = "+v+"\n")
994- if len(w) != 1 || c.Hint.Threshold != 0.05 {
1004+ if len(w) != 1 || c.Hint.Threshold != 0 {
9951005 t.Errorf("hint.threshold = %s: %v %v", v, c.Hint.Threshold, w)
9961006 }
9971007 }
9981008 for _, v := range []string{"0.01", "0.99"} {
999- if c, w := parseString(t, "hint.threshold = "+v+"\n"); len(w) != 0 || c.Hint.Threshold == 0.05 {
1009+ if c, w := parseString(t, "hint.threshold = "+v+"\n"); len(w) != 0 || c.Hint.Threshold == 0 {
10001010 t.Errorf("hint.threshold = %s: %v", v, w)
10011011 }
10021012 }
@@ -974,7 +974,7 @@ func TestHintDetectorConfig(t *testing.T) {
974 t.Setenv("HOME", "/home/u")974 t.Setenv("HOME", "/home/u")
975 t.Setenv("XDG_DATA_HOME", "")975 t.Setenv("XDG_DATA_HOME", "")
976 d := Defaults().Hint976 d := Defaults().Hint
977- if d.Detector != "nefer-hints" || d.Model != "/home/u/.local/share/nefer-hints/models" || d.Threshold != 0.05 {977+ if d.Detector != "nefer-hints" || d.Model != "/home/u/.local/share/nefer-hints/models" || d.Threshold != 0 {
978 t.Fatalf("defaults: %+v", d)978 t.Fatalf("defaults: %+v", d)
979 }979 }
980 t.Setenv("XDG_DATA_HOME", "/data")980 t.Setenv("XDG_DATA_HOME", "/data")
@@ -989,14 +989,24 @@ func TestHintDetectorConfig(t *testing.T) {
989 if len(w) != 0 || c.Hint.Detector != "/opt/nh" || c.Hint.Model != "/opt/models" {989 if len(w) != 0 || c.Hint.Detector != "/opt/nh" || c.Hint.Model != "/opt/models" {
990 t.Fatalf("%+v %v", c.Hint, w)990 t.Fatalf("%+v %v", c.Hint, w)
991 }991 }
992- for _, v := range []string{"0", "0.009", "1", "0.995", "NaN", "high", "-1"} {992+ for _, input := range []string{"", "hint.threshold =\n"} {
993+ c, w := parseString(t, input)
994+ if len(w) != 0 || c.Hint.Threshold != 0 {
995+ t.Errorf("unset threshold %q: %v %v", input, c.Hint.Threshold, w)
996+ }
997+ }
998+ c, w = parseString(t, "hint.threshold = 0.3\nhint.threshold =\n")
999+ if len(w) != 1 || c.Hint.Threshold != 0 { // duplicate-key warning only
1000+ t.Fatalf("cleared threshold: %v %v", c.Hint.Threshold, w)
1001+ }
1002+ for _, v := range []string{"0", "0.009", "1", "0.995", "NaN", "Inf", "-Inf", "high", "-1"} {
993 c, w := parseString(t, "hint.threshold = "+v+"\n")1003 c, w := parseString(t, "hint.threshold = "+v+"\n")
994- if len(w) != 1 || c.Hint.Threshold != 0.05 {1004+ if len(w) != 1 || c.Hint.Threshold != 0 {
995 t.Errorf("hint.threshold = %s: %v %v", v, c.Hint.Threshold, w)1005 t.Errorf("hint.threshold = %s: %v %v", v, c.Hint.Threshold, w)
996 }1006 }
997 }1007 }
998 for _, v := range []string{"0.01", "0.99"} {1008 for _, v := range []string{"0.01", "0.99"} {
999- if c, w := parseString(t, "hint.threshold = "+v+"\n"); len(w) != 0 || c.Hint.Threshold == 0.05 {1009+ if c, w := parseString(t, "hint.threshold = "+v+"\n"); len(w) != 0 || c.Hint.Threshold == 0 {
1000 t.Errorf("hint.threshold = %s: %v", v, w)1010 t.Errorf("hint.threshold = %s: %v", v, w)
1001 }1011 }
1002 }1012 }
M internal/adapters/hints/detector.go +10 -2
@@ -274,10 +274,18 @@ func (p *process) wait() {
274274 })
275275 }
276276
277+// cmd leaves confidence selection to the runner unless explicitly configured.
278+func (d *procDetector) cmd() *exec.Cmd {
279+ args := []string{"--model", d.model, "--imgsz", strconv.Itoa(imgsz), "--gpu", "auto"}
280+ if d.conf != 0 {
281+ args = append(args, "--conf", strconv.FormatFloat(d.conf, 'f', -1, 64))
282+ }
283+ return exec.Command(d.command, args...)
284+}
285+
277286 func (d *procDetector) start(ctx context.Context) error {
278287 log := logging.For(ctx, "hints")
279- cmd := exec.Command(d.command, "--model", d.model, "--imgsz", strconv.Itoa(imgsz),
280- "--conf", strconv.FormatFloat(d.conf, 'f', -1, 64), "--gpu", "auto")
288+ cmd := d.cmd()
281289 cmd.Stderr = &lineLog{log: func(line string) { log.Debug().Str("detector", d.command).Msg(line) }}
282290 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
283291 cmd.WaitDelay = waitDelay
@@ -274,10 +274,18 @@ func (p *process) wait() {
274 })274 })
275 }275 }
276 276
277+// cmd leaves confidence selection to the runner unless explicitly configured.
278+func (d *procDetector) cmd() *exec.Cmd {
279+ args := []string{"--model", d.model, "--imgsz", strconv.Itoa(imgsz), "--gpu", "auto"}
280+ if d.conf != 0 {
281+ args = append(args, "--conf", strconv.FormatFloat(d.conf, 'f', -1, 64))
282+ }
283+ return exec.Command(d.command, args...)
284+}
285+
277 func (d *procDetector) start(ctx context.Context) error {286 func (d *procDetector) start(ctx context.Context) error {
278 log := logging.For(ctx, "hints")287 log := logging.For(ctx, "hints")
279- cmd := exec.Command(d.command, "--model", d.model, "--imgsz", strconv.Itoa(imgsz),288+ cmd := d.cmd()
280- "--conf", strconv.FormatFloat(d.conf, 'f', -1, 64), "--gpu", "auto")
281 cmd.Stderr = &lineLog{log: func(line string) { log.Debug().Str("detector", d.command).Msg(line) }}289 cmd.Stderr = &lineLog{log: func(line string) { log.Debug().Str("detector", d.command).Msg(line) }}
282 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}290 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
283 cmd.WaitDelay = waitDelay291 cmd.WaitDelay = waitDelay
M internal/adapters/hints/detector_test.go +11 -0
@@ -145,3 +145,14 @@ func TestProcDetectorMissingBinary(t *testing.T) {
145145 require.Nil(t, d.proc)
146146 require.NoError(t, d.Close())
147147 }
148+
149+func TestProcDetectorCommand(t *testing.T) {
150+ for _, threshold := range []float64{0, 0.3} {
151+ d := newProcDetector("nefer-hints", "/models", threshold)
152+ want := []string{"nefer-hints", "--model", "/models", "--imgsz", "1280", "--gpu", "auto"}
153+ if threshold != 0 {
154+ want = append(want, "--conf", "0.3")
155+ }
156+ require.Equal(t, want, d.cmd().Args)
157+ }
158+}
@@ -145,3 +145,14 @@ func TestProcDetectorMissingBinary(t *testing.T) {
145 require.Nil(t, d.proc)145 require.Nil(t, d.proc)
146 require.NoError(t, d.Close())146 require.NoError(t, d.Close())
147 }147 }
148+
149+func TestProcDetectorCommand(t *testing.T) {
150+ for _, threshold := range []float64{0, 0.3} {
151+ d := newProcDetector("nefer-hints", "/models", threshold)
152+ want := []string{"nefer-hints", "--model", "/models", "--imgsz", "1280", "--gpu", "auto"}
153+ if threshold != 0 {
154+ want = append(want, "--conf", "0.3")
155+ }
156+ require.Equal(t, want, d.cmd().Args)
157+ }
158+}
M internal/adapters/hints/dump.go +2 -1
@@ -30,7 +30,8 @@ type Options struct {
3030 Command string
3131 // ModelDir is the directory of the detector's model files.
3232 ModelDir string
33- // Threshold is the minimum detection score, 0.01 to 0.99.
33+ // Threshold overrides the minimum detection score, 0.01 to 0.99.
34+ // Zero uses model.json or, if absent, the runner default.
3435 Threshold float64
3536 // DumpDir, when not empty, receives the scanned frames (PNG, annotated
3637 // PNG and JSON). They contain the screen in clear.
@@ -30,7 +30,8 @@ type Options struct {
30 Command string30 Command string
31 // ModelDir is the directory of the detector's model files.31 // ModelDir is the directory of the detector's model files.
32 ModelDir string32 ModelDir string
33- // Threshold is the minimum detection score, 0.01 to 0.99.33+ // Threshold overrides the minimum detection score, 0.01 to 0.99.
34+ // Zero uses model.json or, if absent, the runner default.
34 Threshold float6435 Threshold float64
35 // DumpDir, when not empty, receives the scanned frames (PNG, annotated36 // DumpDir, when not empty, receives the scanned frames (PNG, annotated
36 // PNG and JSON). They contain the screen in clear.37 // PNG and JSON). They contain the screen in clear.
M internal/ports/config.go +2 -1
@@ -178,7 +178,8 @@ type Config struct {
178178 // Detector is the nefer-hints binary (hint.detector): a path, or a
179179 // name looked up in PATH. Model is the directory of its model files
180180 // (hint.model), with a leading ~/ already expanded. Threshold is the
181- // minimum detection score (hint.threshold, 0.01 to 0.99). All three
181+ // minimum detection score (hint.threshold, 0.01 to 0.99); zero leaves
182+ // it unset, using model.json or the runner default. All three
182183 // are read at startup.
183184 Detector, Model string
184185 Threshold float64
@@ -178,7 +178,8 @@ type Config struct {
178 // Detector is the nefer-hints binary (hint.detector): a path, or a178 // Detector is the nefer-hints binary (hint.detector): a path, or a
179 // name looked up in PATH. Model is the directory of its model files179 // name looked up in PATH. Model is the directory of its model files
180 // (hint.model), with a leading ~/ already expanded. Threshold is the180 // (hint.model), with a leading ~/ already expanded. Threshold is the
181- // minimum detection score (hint.threshold, 0.01 to 0.99). All three181+ // minimum detection score (hint.threshold, 0.01 to 0.99); zero leaves
182+ // it unset, using model.json or the runner default. All three
182 // are read at startup.183 // are read at startup.
183 Detector, Model string184 Detector, Model string
184 Threshold float64185 Threshold float64

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close