detached ea2f35e

fix(hints): validate and clamp detector boxes to the frame

commit
ea2f35ee4557cbd3f88f67fb334fb684d199aec3
author
brice · · 2026-10-21 13:38 UTC
committer
brice · · 2026-10-22 20:27 UTC
parent
e8e812d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +63 -24
M internal/adapters/hints/hints.go +16 -9
@@ -223,7 +223,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
223223 res.Err = err
224224 return res
225225 }
226- res.Targets = targetsOf(dets, req.Scale)
226+ res.Targets = targetsOf(dets, req.Scale, req.Width, req.Height)
227227 if w.dumpDir != "" {
228228 // The dump reads the frame after the result is sent and releases it.
229229 w.pending = &dumpJob{when: time.Now(), req: req, pix: fr.pix, m: fr.m, stride: fr.stride, targets: res.Targets, dets: dets, captureD: captureDur, detectD: detectDur}
@@ -236,27 +236,34 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
236236 }
237237
238238 // targetsOf converts boxes in physical pixels to logical output-local
239-// targets, rounding outwards, and drops the ones under minTarget on a side.
240-func targetsOf(dets []detection, scale float64) []ports.HintTarget {
239+// targets. A box with a non-finite coordinate or score, a score outside
240+// [0,1] or no area is dropped; the others are clamped to the width x height
241+// frame (physical), rounded outwards to logical pixels, and dropped when
242+// under minTarget on a side.
243+func targetsOf(dets []detection, scale float64, width, height int) []ports.HintTarget {
241244 if scale <= 0 {
242245 scale = 1
243246 }
247+ fw, fh := float64(width), float64(height)
244248 out := make([]ports.HintTarget, 0, len(dets))
245249 for _, d := range dets {
246250 v := [4]float64{float64(d.x1), float64(d.y1), float64(d.x2), float64(d.y2)}
247- ok := true
251+ ok := d.score >= 0 && d.score <= 1 // false for NaN too
248252 for _, f := range v {
249253 ok = ok && !math.IsNaN(f) && !math.IsInf(f, 0)
250254 }
251- if !ok {
255+ if !ok || v[2] <= v[0] || v[3] <= v[1] {
252256 continue
253257 }
254- x1, y1 := int(math.Floor(v[0]/scale)), int(math.Floor(v[1]/scale))
255- x2, y2 := int(math.Ceil(v[2]/scale)), int(math.Ceil(v[3]/scale))
256- if x2-x1 < minTarget || y2-y1 < minTarget {
258+ // Clamp before any int conversion: huge finite values stay safe.
259+ x1, y1 := math.Max(0, math.Min(v[0], fw)), math.Max(0, math.Min(v[1], fh))
260+ x2, y2 := math.Max(0, math.Min(v[2], fw)), math.Max(0, math.Min(v[3], fh))
261+ lx1, ly1 := int(math.Floor(x1/scale)), int(math.Floor(y1/scale))
262+ lx2, ly2 := int(math.Ceil(x2/scale)), int(math.Ceil(y2/scale))
263+ if lx2-lx1 < minTarget || ly2-ly1 < minTarget {
257264 continue
258265 }
259- out = append(out, ports.HintTarget{Rect: ports.Rect{X: x1, Y: y1, W: x2 - x1, H: y2 - y1}, Score: d.score})
266+ out = append(out, ports.HintTarget{Rect: ports.Rect{X: lx1, Y: ly1, W: lx2 - lx1, H: ly2 - ly1}, Score: d.score})
260267 }
261268 return out
262269 }
@@ -223,7 +223,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
223 res.Err = err223 res.Err = err
224 return res224 return res
225 }225 }
226- res.Targets = targetsOf(dets, req.Scale)226+ res.Targets = targetsOf(dets, req.Scale, req.Width, req.Height)
227 if w.dumpDir != "" {227 if w.dumpDir != "" {
228 // The dump reads the frame after the result is sent and releases it.228 // The dump reads the frame after the result is sent and releases it.
229 w.pending = &dumpJob{when: time.Now(), req: req, pix: fr.pix, m: fr.m, stride: fr.stride, targets: res.Targets, dets: dets, captureD: captureDur, detectD: detectDur}229 w.pending = &dumpJob{when: time.Now(), req: req, pix: fr.pix, m: fr.m, stride: fr.stride, targets: res.Targets, dets: dets, captureD: captureDur, detectD: detectDur}
@@ -236,27 +236,34 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
236 }236 }
237 237
238 // targetsOf converts boxes in physical pixels to logical output-local238 // targetsOf converts boxes in physical pixels to logical output-local
239-// targets, rounding outwards, and drops the ones under minTarget on a side.239+// targets. A box with a non-finite coordinate or score, a score outside
240-func targetsOf(dets []detection, scale float64) []ports.HintTarget {240+// [0,1] or no area is dropped; the others are clamped to the width x height
241+// frame (physical), rounded outwards to logical pixels, and dropped when
242+// under minTarget on a side.
243+func targetsOf(dets []detection, scale float64, width, height int) []ports.HintTarget {
241 if scale <= 0 {244 if scale <= 0 {
242 scale = 1245 scale = 1
243 }246 }
247+ fw, fh := float64(width), float64(height)
244 out := make([]ports.HintTarget, 0, len(dets))248 out := make([]ports.HintTarget, 0, len(dets))
245 for _, d := range dets {249 for _, d := range dets {
246 v := [4]float64{float64(d.x1), float64(d.y1), float64(d.x2), float64(d.y2)}250 v := [4]float64{float64(d.x1), float64(d.y1), float64(d.x2), float64(d.y2)}
247- ok := true251+ ok := d.score >= 0 && d.score <= 1 // false for NaN too
248 for _, f := range v {252 for _, f := range v {
249 ok = ok && !math.IsNaN(f) && !math.IsInf(f, 0)253 ok = ok && !math.IsNaN(f) && !math.IsInf(f, 0)
250 }254 }
251- if !ok {255+ if !ok || v[2] <= v[0] || v[3] <= v[1] {
252 continue256 continue
253 }257 }
254- x1, y1 := int(math.Floor(v[0]/scale)), int(math.Floor(v[1]/scale))258+ // Clamp before any int conversion: huge finite values stay safe.
255- x2, y2 := int(math.Ceil(v[2]/scale)), int(math.Ceil(v[3]/scale))259+ x1, y1 := math.Max(0, math.Min(v[0], fw)), math.Max(0, math.Min(v[1], fh))
256- if x2-x1 < minTarget || y2-y1 < minTarget {260+ x2, y2 := math.Max(0, math.Min(v[2], fw)), math.Max(0, math.Min(v[3], fh))
261+ lx1, ly1 := int(math.Floor(x1/scale)), int(math.Floor(y1/scale))
262+ lx2, ly2 := int(math.Ceil(x2/scale)), int(math.Ceil(y2/scale))
263+ if lx2-lx1 < minTarget || ly2-ly1 < minTarget {
257 continue264 continue
258 }265 }
259- out = append(out, ports.HintTarget{Rect: ports.Rect{X: x1, Y: y1, W: x2 - x1, H: y2 - y1}, Score: d.score})266+ out = append(out, ports.HintTarget{Rect: ports.Rect{X: lx1, Y: ly1, W: lx2 - lx1, H: ly2 - ly1}, Score: d.score})
260 }267 }
261 return out268 return out
262 }269 }
M internal/adapters/hints/hints_test.go +47 -15
@@ -67,7 +67,7 @@ func TestHintScan(t *testing.T) {
6767 var got detFrame
6868 det.EXPECT().Detect(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f detFrame) ([]detection, error) {
6969 got = f
70- return []detection{{x1: 10, y1: 20, x2: 110, y2: 60, score: 0.8}}, nil
70+ return []detection{{x1: 10, y1: 20, x2: 50, y2: 30, score: 0.8}}, nil
7171 }).Once()
7272 h := startWith(t, 5*time.Second, Options{detector: det})
7373 go func() {
@@ -79,26 +79,58 @@ func TestHintScan(t *testing.T) {
7979 require.NoError(t, res.Err)
8080 require.Equal(t, uint64(7), res.Seq)
8181 require.Equal(t, "HEADLESS-1", res.Output)
82- require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 40}, Score: 0.8}}, res.Targets)
82+ require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 40, H: 10}, Score: 0.8}}, res.Targets)
8383 require.Equal(t, 64, got.width)
8484 require.Equal(t, 32, got.height)
8585 require.Equal(t, 64*4, got.stride)
8686 }
8787
8888 func TestTargetsOf(t *testing.T) {
89- got := targetsOf([]detection{
90- {x1: 11, y1: 21, x2: 100.2, y2: 61, score: 0.5}, // scale 2: x 5..51, y 10..31
91- {x1: 0, y1: 0, x2: 5, y2: 40, score: 0.9}, // 2.5 -> 3 logical wide: dropped
92- {x1: 0, y1: 0, x2: 40, y2: 5, score: 0.9}, // 3 logical high: dropped
93- {x1: 0, y1: 0, x2: 8, y2: 8, score: 0.4}, // exactly 4x4: kept
94- {x1: float32(math.NaN()), y1: 0, x2: 50, y2: 50},
95- }, 2)
96- require.Equal(t, []ports.HintTarget{
97- {Rect: ports.Rect{X: 5, Y: 10, W: 46, H: 21}, Score: 0.5},
98- {Rect: ports.Rect{X: 0, Y: 0, W: 4, H: 4}, Score: 0.4},
99- }, got)
100- // A zero scale reads as 1.
101- require.Len(t, targetsOf([]detection{{x2: 10, y2: 10}}, 0), 1)
89+ nan := float32(math.NaN())
90+ inf := float32(math.Inf(1))
91+ r := func(x, y, w, h int, score float32) []ports.HintTarget {
92+ return []ports.HintTarget{{Rect: ports.Rect{X: x, Y: y, W: w, H: h}, Score: score}}
93+ }
94+ for _, tc := range []struct {
95+ name string
96+ det detection
97+ scale float64
98+ want []ports.HintTarget
99+ }{
100+ {"inside", detection{10, 20, 50, 40, 0.5}, 1, r(10, 20, 40, 20, 0.5)},
101+ {"scale 2 rounds outwards, clamped at 100", detection{11, 21, 100.2, 61, 0.5}, 2, r(5, 10, 45, 21, 0.5)},
102+ {"zero scale reads as 1", detection{0, 0, 10, 10, 0.5}, 0, r(0, 0, 10, 10, 0.5)},
103+ {"exactly 4x4 kept", detection{0, 0, 8, 8, 0.4}, 2, r(0, 0, 4, 4, 0.4)},
104+ {"3 logical wide dropped", detection{0, 0, 5, 40, 0.9}, 2, nil},
105+ {"3 logical high dropped", detection{0, 0, 40, 5, 0.9}, 2, nil},
106+ {"fully outside", detection{200, 10, 300, 50, 0.5}, 1, nil},
107+ {"fully before origin", detection{-50, -50, -10, -10, 0.5}, 1, nil},
108+ {"partly outside is clamped", detection{-20, 70, 150, 120, 0.5}, 1, r(0, 70, 100, 30, 0.5)},
109+ {"partly outside, scale 2", detection{-20, 70, 150, 120, 0.5}, 2, r(0, 35, 50, 15, 0.5)},
110+ {"inverted x", detection{50, 10, 20, 40, 0.5}, 1, nil},
111+ {"inverted y", detection{10, 40, 50, 10, 0.5}, 1, nil},
112+ {"empty x", detection{10, 10, 10, 40, 0.5}, 1, nil},
113+ {"empty y", detection{10, 10, 40, 10, 0.5}, 1, nil},
114+ {"NaN coordinate", detection{nan, 0, 50, 50, 0.5}, 1, nil},
115+ {"Inf coordinate", detection{0, 0, inf, 50, 0.5}, 1, nil},
116+ {"NaN score", detection{0, 0, 50, 50, nan}, 1, nil},
117+ {"Inf score", detection{0, 0, 50, 50, inf}, 1, nil},
118+ {"score above 1", detection{0, 0, 50, 50, 1.5}, 1, nil},
119+ {"negative score", detection{0, 0, 50, 50, -0.1}, 1, nil},
120+ {"score 0 and 1 kept", detection{0, 0, 50, 50, 1}, 1, r(0, 0, 50, 50, 1)},
121+ {"huge coordinates", detection{-1e30, -1e30, 1e30, 1e30, 0.5}, 1, r(0, 0, 100, 100, 0.5)},
122+ {"huge positive only", detection{10, 10, 1e30, 1e30, 0.5}, 2, r(5, 5, 45, 45, 0.5)},
123+ {"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5}, 1, nil},
124+ } {
125+ t.Run(tc.name, func(t *testing.T) {
126+ got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)
127+ if tc.want == nil {
128+ require.Empty(t, got)
129+ return
130+ }
131+ require.Equal(t, tc.want, got)
132+ })
133+ }
102134 }
103135
104136 // A detector error fails the scan; the worker goes on and the buffer is released.
@@ -67,7 +67,7 @@ func TestHintScan(t *testing.T) {
67 var got detFrame67 var got detFrame
68 det.EXPECT().Detect(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f detFrame) ([]detection, error) {68 det.EXPECT().Detect(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f detFrame) ([]detection, error) {
69 got = f69 got = f
70- return []detection{{x1: 10, y1: 20, x2: 110, y2: 60, score: 0.8}}, nil70+ return []detection{{x1: 10, y1: 20, x2: 50, y2: 30, score: 0.8}}, nil
71 }).Once()71 }).Once()
72 h := startWith(t, 5*time.Second, Options{detector: det})72 h := startWith(t, 5*time.Second, Options{detector: det})
73 go func() {73 go func() {
@@ -79,26 +79,58 @@ func TestHintScan(t *testing.T) {
79 require.NoError(t, res.Err)79 require.NoError(t, res.Err)
80 require.Equal(t, uint64(7), res.Seq)80 require.Equal(t, uint64(7), res.Seq)
81 require.Equal(t, "HEADLESS-1", res.Output)81 require.Equal(t, "HEADLESS-1", res.Output)
82- require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 40}, Score: 0.8}}, res.Targets)82+ require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 40, H: 10}, Score: 0.8}}, res.Targets)
83 require.Equal(t, 64, got.width)83 require.Equal(t, 64, got.width)
84 require.Equal(t, 32, got.height)84 require.Equal(t, 32, got.height)
85 require.Equal(t, 64*4, got.stride)85 require.Equal(t, 64*4, got.stride)
86 }86 }
87 87
88 func TestTargetsOf(t *testing.T) {88 func TestTargetsOf(t *testing.T) {
89- got := targetsOf([]detection{89+ nan := float32(math.NaN())
90- {x1: 11, y1: 21, x2: 100.2, y2: 61, score: 0.5}, // scale 2: x 5..51, y 10..3190+ inf := float32(math.Inf(1))
91- {x1: 0, y1: 0, x2: 5, y2: 40, score: 0.9}, // 2.5 -> 3 logical wide: dropped91+ r := func(x, y, w, h int, score float32) []ports.HintTarget {
92- {x1: 0, y1: 0, x2: 40, y2: 5, score: 0.9}, // 3 logical high: dropped92+ return []ports.HintTarget{{Rect: ports.Rect{X: x, Y: y, W: w, H: h}, Score: score}}
93- {x1: 0, y1: 0, x2: 8, y2: 8, score: 0.4}, // exactly 4x4: kept93+ }
94- {x1: float32(math.NaN()), y1: 0, x2: 50, y2: 50},94+ for _, tc := range []struct {
95- }, 2)95+ name string
96- require.Equal(t, []ports.HintTarget{96+ det detection
97- {Rect: ports.Rect{X: 5, Y: 10, W: 46, H: 21}, Score: 0.5},97+ scale float64
98- {Rect: ports.Rect{X: 0, Y: 0, W: 4, H: 4}, Score: 0.4},98+ want []ports.HintTarget
99- }, got)99+ }{
100- // A zero scale reads as 1.100+ {"inside", detection{10, 20, 50, 40, 0.5}, 1, r(10, 20, 40, 20, 0.5)},
101- require.Len(t, targetsOf([]detection{{x2: 10, y2: 10}}, 0), 1)101+ {"scale 2 rounds outwards, clamped at 100", detection{11, 21, 100.2, 61, 0.5}, 2, r(5, 10, 45, 21, 0.5)},
102+ {"zero scale reads as 1", detection{0, 0, 10, 10, 0.5}, 0, r(0, 0, 10, 10, 0.5)},
103+ {"exactly 4x4 kept", detection{0, 0, 8, 8, 0.4}, 2, r(0, 0, 4, 4, 0.4)},
104+ {"3 logical wide dropped", detection{0, 0, 5, 40, 0.9}, 2, nil},
105+ {"3 logical high dropped", detection{0, 0, 40, 5, 0.9}, 2, nil},
106+ {"fully outside", detection{200, 10, 300, 50, 0.5}, 1, nil},
107+ {"fully before origin", detection{-50, -50, -10, -10, 0.5}, 1, nil},
108+ {"partly outside is clamped", detection{-20, 70, 150, 120, 0.5}, 1, r(0, 70, 100, 30, 0.5)},
109+ {"partly outside, scale 2", detection{-20, 70, 150, 120, 0.5}, 2, r(0, 35, 50, 15, 0.5)},
110+ {"inverted x", detection{50, 10, 20, 40, 0.5}, 1, nil},
111+ {"inverted y", detection{10, 40, 50, 10, 0.5}, 1, nil},
112+ {"empty x", detection{10, 10, 10, 40, 0.5}, 1, nil},
113+ {"empty y", detection{10, 10, 40, 10, 0.5}, 1, nil},
114+ {"NaN coordinate", detection{nan, 0, 50, 50, 0.5}, 1, nil},
115+ {"Inf coordinate", detection{0, 0, inf, 50, 0.5}, 1, nil},
116+ {"NaN score", detection{0, 0, 50, 50, nan}, 1, nil},
117+ {"Inf score", detection{0, 0, 50, 50, inf}, 1, nil},
118+ {"score above 1", detection{0, 0, 50, 50, 1.5}, 1, nil},
119+ {"negative score", detection{0, 0, 50, 50, -0.1}, 1, nil},
120+ {"score 0 and 1 kept", detection{0, 0, 50, 50, 1}, 1, r(0, 0, 50, 50, 1)},
121+ {"huge coordinates", detection{-1e30, -1e30, 1e30, 1e30, 0.5}, 1, r(0, 0, 100, 100, 0.5)},
122+ {"huge positive only", detection{10, 10, 1e30, 1e30, 0.5}, 2, r(5, 5, 45, 45, 0.5)},
123+ {"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5}, 1, nil},
124+ } {
125+ t.Run(tc.name, func(t *testing.T) {
126+ got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)
127+ if tc.want == nil {
128+ require.Empty(t, got)
129+ return
130+ }
131+ require.Equal(t, tc.want, got)
132+ })
133+ }
102 }134 }
103 135
104 // A detector error fails the scan; the worker goes on and the buffer is released.136 // A detector error fails the scan; the worker goes on and the buffer is released.

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 23

Tags 10

↑↓ moveenter openesc close