detached deb7116

feat(hints): tile wide outputs into 16:9 regions

commit
deb7116c76f685458b1951f0b1d2c1a9dcf4fd22
author
brice · · 2026-10-21 15:10 UTC
parent
ba22f86
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +264 -35
M internal/adapters/hints/detector.go +25 -6
@@ -26,11 +26,16 @@ import (
2626 type detFrame struct {
2727 width, height, stride int
2828 m *mapping
29+ // regions are the crops detected independently; none means the whole
30+ // frame.
31+ regions []region
2932 }
3033
3134 // detection is one box found by the detector, in frame (physical) pixels.
3235 type detection struct {
3336 x1, y1, x2, y2, score float32
37+ // region is the index of the request region the box comes from.
38+ region int
3439 }
3540
3641 // detector finds clickable elements in a frame. The worker goroutine is its
@@ -68,10 +73,10 @@ var (
6873
6974 var le = binary.LittleEndian
7075
71-// writeRequest writes one whole-frame request (no region, default
72-// confidence) followed by the pixels.
76+// writeRequest writes one request (the regions of f, default confidence)
77+// followed by the pixels.
7378 func writeRequest(w io.Writer, seq uint64, f detFrame) error {
74- var h [4 + 4 + 8 + 5*4 + 4]byte
79+ var h [4 + 4 + 8 + 5*4]byte
7580 copy(h[0:], magicRequest[:])
7681 le.PutUint32(h[4:], protocolVersion)
7782 le.PutUint64(h[8:], seq)
@@ -79,11 +84,25 @@ func writeRequest(w io.Writer, seq uint64, f detFrame) error {
7984 le.PutUint32(h[20:], uint32(f.height))
8085 le.PutUint32(h[24:], uint32(f.stride))
8186 le.PutUint32(h[28:], formatXRGB8888)
82- le.PutUint32(h[32:], 0) // nregions
83- le.PutUint32(h[36:], math.Float32bits(0)) // confidence: startup default
87+ le.PutUint32(h[32:], uint32(len(f.regions)))
8488 if _, err := w.Write(h[:]); err != nil {
8589 return err
8690 }
91+ var rec [16]byte
92+ for _, r := range f.regions {
93+ le.PutUint32(rec[0:], uint32(int32(r.x)))
94+ le.PutUint32(rec[4:], uint32(int32(r.y)))
95+ le.PutUint32(rec[8:], uint32(r.w))
96+ le.PutUint32(rec[12:], uint32(r.h))
97+ if _, err := w.Write(rec[:]); err != nil {
98+ return err
99+ }
100+ }
101+ var conf [4]byte // confidence: startup default
102+ le.PutUint32(conf[:], math.Float32bits(0))
103+ if _, err := w.Write(conf[:]); err != nil {
104+ return err
105+ }
87106 _, err := w.Write(f.m.b[:f.height*f.stride])
88107 return err
89108 }
@@ -184,7 +203,7 @@ func readResponse(r io.Reader, seq uint64) ([]detection, error) {
184203 return nil, err
185204 }
186205 f := func(i int) float32 { return math.Float32frombits(le.Uint32(rec[i*4:])) }
187- out = append(out, detection{x1: f(0), y1: f(1), x2: f(2), y2: f(3), score: f(4)})
206+ out = append(out, detection{x1: f(0), y1: f(1), x2: f(2), y2: f(3), score: f(4), region: int(le.Uint32(rec[20:]))})
188207 }
189208 return out, nil
190209 }
@@ -26,11 +26,16 @@ import (
26 type detFrame struct {26 type detFrame struct {
27 width, height, stride int27 width, height, stride int
28 m *mapping28 m *mapping
29+ // regions are the crops detected independently; none means the whole
30+ // frame.
31+ regions []region
29 }32 }
30 33
31 // detection is one box found by the detector, in frame (physical) pixels.34 // detection is one box found by the detector, in frame (physical) pixels.
32 type detection struct {35 type detection struct {
33 x1, y1, x2, y2, score float3236 x1, y1, x2, y2, score float32
37+ // region is the index of the request region the box comes from.
38+ region int
34 }39 }
35 40
36 // detector finds clickable elements in a frame. The worker goroutine is its41 // detector finds clickable elements in a frame. The worker goroutine is its
@@ -68,10 +73,10 @@ var (
68 73
69 var le = binary.LittleEndian74 var le = binary.LittleEndian
70 75
71-// writeRequest writes one whole-frame request (no region, default76+// writeRequest writes one request (the regions of f, default confidence)
72-// confidence) followed by the pixels.77+// followed by the pixels.
73 func writeRequest(w io.Writer, seq uint64, f detFrame) error {78 func writeRequest(w io.Writer, seq uint64, f detFrame) error {
74- var h [4 + 4 + 8 + 5*4 + 4]byte79+ var h [4 + 4 + 8 + 5*4]byte
75 copy(h[0:], magicRequest[:])80 copy(h[0:], magicRequest[:])
76 le.PutUint32(h[4:], protocolVersion)81 le.PutUint32(h[4:], protocolVersion)
77 le.PutUint64(h[8:], seq)82 le.PutUint64(h[8:], seq)
@@ -79,11 +84,25 @@ func writeRequest(w io.Writer, seq uint64, f detFrame) error {
79 le.PutUint32(h[20:], uint32(f.height))84 le.PutUint32(h[20:], uint32(f.height))
80 le.PutUint32(h[24:], uint32(f.stride))85 le.PutUint32(h[24:], uint32(f.stride))
81 le.PutUint32(h[28:], formatXRGB8888)86 le.PutUint32(h[28:], formatXRGB8888)
82- le.PutUint32(h[32:], 0) // nregions87+ le.PutUint32(h[32:], uint32(len(f.regions)))
83- le.PutUint32(h[36:], math.Float32bits(0)) // confidence: startup default
84 if _, err := w.Write(h[:]); err != nil {88 if _, err := w.Write(h[:]); err != nil {
85 return err89 return err
86 }90 }
91+ var rec [16]byte
92+ for _, r := range f.regions {
93+ le.PutUint32(rec[0:], uint32(int32(r.x)))
94+ le.PutUint32(rec[4:], uint32(int32(r.y)))
95+ le.PutUint32(rec[8:], uint32(r.w))
96+ le.PutUint32(rec[12:], uint32(r.h))
97+ if _, err := w.Write(rec[:]); err != nil {
98+ return err
99+ }
100+ }
101+ var conf [4]byte // confidence: startup default
102+ le.PutUint32(conf[:], math.Float32bits(0))
103+ if _, err := w.Write(conf[:]); err != nil {
104+ return err
105+ }
87 _, err := w.Write(f.m.b[:f.height*f.stride])106 _, err := w.Write(f.m.b[:f.height*f.stride])
88 return err107 return err
89 }108 }
@@ -184,7 +203,7 @@ func readResponse(r io.Reader, seq uint64) ([]detection, error) {
184 return nil, err203 return nil, err
185 }204 }
186 f := func(i int) float32 { return math.Float32frombits(le.Uint32(rec[i*4:])) }205 f := func(i int) float32 { return math.Float32frombits(le.Uint32(rec[i*4:])) }
187- out = append(out, detection{x1: f(0), y1: f(1), x2: f(2), y2: f(3), score: f(4)})206+ out = append(out, detection{x1: f(0), y1: f(1), x2: f(2), y2: f(3), score: f(4), region: int(le.Uint32(rec[20:]))})
188 }207 }
189 return out, nil208 return out, nil
190 }209 }
M internal/adapters/hints/detector_test.go +12 -2
@@ -28,6 +28,16 @@ func TestWriteRequest(t *testing.T) {
2828 require.Equal(t, want, buf.Bytes())
2929 }
3030
31+func TestWriteRequestRegions(t *testing.T) {
32+ var buf bytes.Buffer
33+ pix := []byte{1, 2, 3, 4}
34+ f := detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: pix}, regions: []region{{0, 1, 2, 3}, {4, 5, 6, 7}}}
35+ require.NoError(t, writeRequest(&buf, 1, f))
36+ want := cat([]byte("NHRQ"), u32(1), u64(1), u32(1), u32(1), u32(4), u32(1), u32(2),
37+ u32(0), u32(1), u32(2), u32(3), u32(4), u32(5), u32(6), u32(7), f32(0), pix)
38+ require.Equal(t, want, buf.Bytes())
39+}
40+
3141 func TestReadHello(t *testing.T) {
3242 dev, err := readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(0), u32(6), []byte("Vulkan"), u32(1280))))
3343 require.NoError(t, err)
@@ -52,10 +62,10 @@ func TestReadResponse(t *testing.T) {
5262
5363 ok := cat(head(5, 0), f32(12.5), u32(2),
5464 f32(1), f32(2), f32(30), f32(40), f32(0.9), u32(0),
55- f32(5), f32(6), f32(7), f32(8), f32(0.25), u32(0))
65+ f32(5), f32(6), f32(7), f32(8), f32(0.25), u32(3))
5666 got, err := readResponse(bytes.NewReader(ok), 5)
5767 require.NoError(t, err)
58- require.Equal(t, []detection{{1, 2, 30, 40, 0.9}, {5, 6, 7, 8, 0.25}}, got)
68+ require.Equal(t, []detection{{x1: 1, y1: 2, x2: 30, y2: 40, score: 0.9}, {x1: 5, y1: 6, x2: 7, y2: 8, score: 0.25, region: 3}}, got)
5969
6070 got, err = readResponse(bytes.NewReader(cat(head(5, 0), f32(1), u32(0))), 5)
6171 require.NoError(t, err)
@@ -28,6 +28,16 @@ func TestWriteRequest(t *testing.T) {
28 require.Equal(t, want, buf.Bytes())28 require.Equal(t, want, buf.Bytes())
29 }29 }
30 30
31+func TestWriteRequestRegions(t *testing.T) {
32+ var buf bytes.Buffer
33+ pix := []byte{1, 2, 3, 4}
34+ f := detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: pix}, regions: []region{{0, 1, 2, 3}, {4, 5, 6, 7}}}
35+ require.NoError(t, writeRequest(&buf, 1, f))
36+ want := cat([]byte("NHRQ"), u32(1), u64(1), u32(1), u32(1), u32(4), u32(1), u32(2),
37+ u32(0), u32(1), u32(2), u32(3), u32(4), u32(5), u32(6), u32(7), f32(0), pix)
38+ require.Equal(t, want, buf.Bytes())
39+}
40+
31 func TestReadHello(t *testing.T) {41 func TestReadHello(t *testing.T) {
32 dev, err := readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(0), u32(6), []byte("Vulkan"), u32(1280))))42 dev, err := readHello(bytes.NewReader(cat([]byte("NHNT"), u32(1), u32(0), u32(6), []byte("Vulkan"), u32(1280))))
33 require.NoError(t, err)43 require.NoError(t, err)
@@ -52,10 +62,10 @@ func TestReadResponse(t *testing.T) {
52 62
53 ok := cat(head(5, 0), f32(12.5), u32(2),63 ok := cat(head(5, 0), f32(12.5), u32(2),
54 f32(1), f32(2), f32(30), f32(40), f32(0.9), u32(0),64 f32(1), f32(2), f32(30), f32(40), f32(0.9), u32(0),
55- f32(5), f32(6), f32(7), f32(8), f32(0.25), u32(0))65+ f32(5), f32(6), f32(7), f32(8), f32(0.25), u32(3))
56 got, err := readResponse(bytes.NewReader(ok), 5)66 got, err := readResponse(bytes.NewReader(ok), 5)
57 require.NoError(t, err)67 require.NoError(t, err)
58- require.Equal(t, []detection{{1, 2, 30, 40, 0.9}, {5, 6, 7, 8, 0.25}}, got)68+ require.Equal(t, []detection{{x1: 1, y1: 2, x2: 30, y2: 40, score: 0.9}, {x1: 5, y1: 6, x2: 7, y2: 8, score: 0.25, region: 3}}, got)
59 69
60 got, err = readResponse(bytes.NewReader(cat(head(5, 0), f32(1), u32(0))), 5)70 got, err = readResponse(bytes.NewReader(cat(head(5, 0), f32(1), u32(0))), 5)
61 require.NoError(t, err)71 require.NoError(t, err)
M internal/adapters/hints/hints.go +6 -3
@@ -215,7 +215,8 @@ const minTarget = 4
215215 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
216216 log := logging.For(ctx, "hints")
217217 t0 := time.Now()
218- dets, err := w.det.Detect(ctx, detFrame{width: req.Width, height: req.Height, stride: fr.stride, m: fr.m})
218+ regions := tileRegions(req.Width, req.Height)
219+ dets, err := w.det.Detect(ctx, detFrame{width: req.Width, height: req.Height, stride: fr.stride, m: fr.m, regions: regions})
219220 detectDur := time.Since(t0)
220221 if err != nil {
221222 fr.m.unmap()
@@ -223,8 +224,10 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
223224 res.Err = err
224225 return res
225226 }
226- // Merge after rounding to logical pixels, on the union of all boxes.
227- res.Targets = mergeTargets(targetsOf(dets, req.Scale, req.Width, req.Height))
227+ // Boxes cut by a tile's inner edge are dropped when a neighbour tile
228+ // saw the element whole; then, on the union of all tiles and after
229+ // rounding to logical pixels, duplicates and nested boxes are merged.
230+ res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height))
228231 if w.dumpDir != "" {
229232 // The dump reads the frame after the result is sent and releases it.
230233 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}
@@ -215,7 +215,8 @@ const minTarget = 4
215 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {215 func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult {
216 log := logging.For(ctx, "hints")216 log := logging.For(ctx, "hints")
217 t0 := time.Now()217 t0 := time.Now()
218- dets, err := w.det.Detect(ctx, detFrame{width: req.Width, height: req.Height, stride: fr.stride, m: fr.m})218+ regions := tileRegions(req.Width, req.Height)
219+ dets, err := w.det.Detect(ctx, detFrame{width: req.Width, height: req.Height, stride: fr.stride, m: fr.m, regions: regions})
219 detectDur := time.Since(t0)220 detectDur := time.Since(t0)
220 if err != nil {221 if err != nil {
221 fr.m.unmap()222 fr.m.unmap()
@@ -223,8 +224,10 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame,
223 res.Err = err224 res.Err = err
224 return res225 return res
225 }226 }
226- // Merge after rounding to logical pixels, on the union of all boxes.227+ // Boxes cut by a tile's inner edge are dropped when a neighbour tile
227- res.Targets = mergeTargets(targetsOf(dets, req.Scale, req.Width, req.Height))228+ // saw the element whole; then, on the union of all tiles and after
229+ // rounding to logical pixels, duplicates and nested boxes are merged.
230+ res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height))
228 if w.dumpDir != "" {231 if w.dumpDir != "" {
229 // The dump reads the frame after the result is sent and releases it.232 // The dump reads the frame after the result is sent and releases it.
230 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}233 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}
M internal/adapters/hints/hints_test.go +24 -24
@@ -97,30 +97,30 @@ func TestTargetsOf(t *testing.T) {
9797 scale float64
9898 want []ports.HintTarget
9999 }{
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},
100+ {"inside", detection{10, 20, 50, 40, 0.5, 0}, 1, r(10, 20, 40, 20, 0.5)},
101+ {"scale 2 rounds outwards, clamped at 100", detection{11, 21, 100.2, 61, 0.5, 0}, 2, r(5, 10, 45, 21, 0.5)},
102+ {"zero scale reads as 1", detection{0, 0, 10, 10, 0.5, 0}, 0, r(0, 0, 10, 10, 0.5)},
103+ {"exactly 4x4 kept", detection{0, 0, 8, 8, 0.4, 0}, 2, r(0, 0, 4, 4, 0.4)},
104+ {"3 logical wide dropped", detection{0, 0, 5, 40, 0.9, 0}, 2, nil},
105+ {"3 logical high dropped", detection{0, 0, 40, 5, 0.9, 0}, 2, nil},
106+ {"fully outside", detection{200, 10, 300, 50, 0.5, 0}, 1, nil},
107+ {"fully before origin", detection{-50, -50, -10, -10, 0.5, 0}, 1, nil},
108+ {"partly outside is clamped", detection{-20, 70, 150, 120, 0.5, 0}, 1, r(0, 70, 100, 30, 0.5)},
109+ {"partly outside, scale 2", detection{-20, 70, 150, 120, 0.5, 0}, 2, r(0, 35, 50, 15, 0.5)},
110+ {"inverted x", detection{50, 10, 20, 40, 0.5, 0}, 1, nil},
111+ {"inverted y", detection{10, 40, 50, 10, 0.5, 0}, 1, nil},
112+ {"empty x", detection{10, 10, 10, 40, 0.5, 0}, 1, nil},
113+ {"empty y", detection{10, 10, 40, 10, 0.5, 0}, 1, nil},
114+ {"NaN coordinate", detection{nan, 0, 50, 50, 0.5, 0}, 1, nil},
115+ {"Inf coordinate", detection{0, 0, inf, 50, 0.5, 0}, 1, nil},
116+ {"NaN score", detection{0, 0, 50, 50, nan, 0}, 1, nil},
117+ {"Inf score", detection{0, 0, 50, 50, inf, 0}, 1, nil},
118+ {"score above 1", detection{0, 0, 50, 50, 1.5, 0}, 1, nil},
119+ {"negative score", detection{0, 0, 50, 50, -0.1, 0}, 1, nil},
120+ {"score 0 and 1 kept", detection{0, 0, 50, 50, 1, 0}, 1, r(0, 0, 50, 50, 1)},
121+ {"huge coordinates", detection{-1e30, -1e30, 1e30, 1e30, 0.5, 0}, 1, r(0, 0, 100, 100, 0.5)},
122+ {"huge positive only", detection{10, 10, 1e30, 1e30, 0.5, 0}, 2, r(5, 5, 45, 45, 0.5)},
123+ {"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5, 0}, 1, nil},
124124 } {
125125 t.Run(tc.name, func(t *testing.T) {
126126 got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)
@@ -97,30 +97,30 @@ func TestTargetsOf(t *testing.T) {
97 scale float6497 scale float64
98 want []ports.HintTarget98 want []ports.HintTarget
99 }{99 }{
100- {"inside", detection{10, 20, 50, 40, 0.5}, 1, r(10, 20, 40, 20, 0.5)},100+ {"inside", detection{10, 20, 50, 40, 0.5, 0}, 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)},101+ {"scale 2 rounds outwards, clamped at 100", detection{11, 21, 100.2, 61, 0.5, 0}, 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)},102+ {"zero scale reads as 1", detection{0, 0, 10, 10, 0.5, 0}, 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)},103+ {"exactly 4x4 kept", detection{0, 0, 8, 8, 0.4, 0}, 2, r(0, 0, 4, 4, 0.4)},
104- {"3 logical wide dropped", detection{0, 0, 5, 40, 0.9}, 2, nil},104+ {"3 logical wide dropped", detection{0, 0, 5, 40, 0.9, 0}, 2, nil},
105- {"3 logical high dropped", detection{0, 0, 40, 5, 0.9}, 2, nil},105+ {"3 logical high dropped", detection{0, 0, 40, 5, 0.9, 0}, 2, nil},
106- {"fully outside", detection{200, 10, 300, 50, 0.5}, 1, nil},106+ {"fully outside", detection{200, 10, 300, 50, 0.5, 0}, 1, nil},
107- {"fully before origin", detection{-50, -50, -10, -10, 0.5}, 1, nil},107+ {"fully before origin", detection{-50, -50, -10, -10, 0.5, 0}, 1, nil},
108- {"partly outside is clamped", detection{-20, 70, 150, 120, 0.5}, 1, r(0, 70, 100, 30, 0.5)},108+ {"partly outside is clamped", detection{-20, 70, 150, 120, 0.5, 0}, 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)},109+ {"partly outside, scale 2", detection{-20, 70, 150, 120, 0.5, 0}, 2, r(0, 35, 50, 15, 0.5)},
110- {"inverted x", detection{50, 10, 20, 40, 0.5}, 1, nil},110+ {"inverted x", detection{50, 10, 20, 40, 0.5, 0}, 1, nil},
111- {"inverted y", detection{10, 40, 50, 10, 0.5}, 1, nil},111+ {"inverted y", detection{10, 40, 50, 10, 0.5, 0}, 1, nil},
112- {"empty x", detection{10, 10, 10, 40, 0.5}, 1, nil},112+ {"empty x", detection{10, 10, 10, 40, 0.5, 0}, 1, nil},
113- {"empty y", detection{10, 10, 40, 10, 0.5}, 1, nil},113+ {"empty y", detection{10, 10, 40, 10, 0.5, 0}, 1, nil},
114- {"NaN coordinate", detection{nan, 0, 50, 50, 0.5}, 1, nil},114+ {"NaN coordinate", detection{nan, 0, 50, 50, 0.5, 0}, 1, nil},
115- {"Inf coordinate", detection{0, 0, inf, 50, 0.5}, 1, nil},115+ {"Inf coordinate", detection{0, 0, inf, 50, 0.5, 0}, 1, nil},
116- {"NaN score", detection{0, 0, 50, 50, nan}, 1, nil},116+ {"NaN score", detection{0, 0, 50, 50, nan, 0}, 1, nil},
117- {"Inf score", detection{0, 0, 50, 50, inf}, 1, nil},117+ {"Inf score", detection{0, 0, 50, 50, inf, 0}, 1, nil},
118- {"score above 1", detection{0, 0, 50, 50, 1.5}, 1, nil},118+ {"score above 1", detection{0, 0, 50, 50, 1.5, 0}, 1, nil},
119- {"negative score", detection{0, 0, 50, 50, -0.1}, 1, nil},119+ {"negative score", detection{0, 0, 50, 50, -0.1, 0}, 1, nil},
120- {"score 0 and 1 kept", detection{0, 0, 50, 50, 1}, 1, r(0, 0, 50, 50, 1)},120+ {"score 0 and 1 kept", detection{0, 0, 50, 50, 1, 0}, 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)},121+ {"huge coordinates", detection{-1e30, -1e30, 1e30, 1e30, 0.5, 0}, 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)},122+ {"huge positive only", detection{10, 10, 1e30, 1e30, 0.5, 0}, 2, r(5, 5, 45, 45, 0.5)},
123- {"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5}, 1, nil},123+ {"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5, 0}, 1, nil},
124 } {124 } {
125 t.Run(tc.name, func(t *testing.T) {125 t.Run(tc.name, func(t *testing.T) {
126 got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)126 got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)
A internal/adapters/hints/tile.go +122 -0
@@ -0,0 +1,122 @@
1+package hints
2+
3+const (
4+ // maxRegions is the protocol limit of regions per request.
5+ maxRegions = 64
6+ // wholeMaxSide is the longest frame side sent whole when the frame is not
7+ // 16:9: up to there the square letterbox of the detector loses little.
8+ wholeMaxSide = 2560
9+ // tileMaxH is the tallest tile; its width is 16/9 of it.
10+ tileMaxH = 1440
11+ // tileCutMargin is the distance, in physical pixels, to a tile's inner
12+ // edge under which a box counts as cut by it.
13+ tileCutMargin = 2
14+)
15+
16+// region is a crop of the frame, in physical pixels.
17+type region struct{ x, y, w, h int }
18+
19+// tileRegions returns the crops to detect separately on a width x height
20+// frame, or nil for one whole-frame detection (a 16:9 frame, or one whose
21+// long side is at most wholeMaxSide). Otherwise the frame is covered by exact
22+// 16:9 tiles (w*9 == h*16: the detector then uses its rectangular graph) of
23+// height min(height, 1440) rounded down to a multiple of 9, overlapping by at
24+// least 10 % of the tile side on each axis; the last tile of a row or column
25+// is aligned to the frame edge. Tiles are listed row by row. A frame that
26+// would need more than maxRegions tiles is detected whole.
27+func tileRegions(width, height int) []region {
28+ if width <= 0 || height <= 0 || width*9 == height*16 || max(width, height) <= wholeMaxSide {
29+ return nil
30+ }
31+ th := min(height, tileMaxH) / 9 * 9
32+ tw := th / 9 * 16
33+ if tw > width { // a tall frame: the width bounds the tile
34+ tw = width / 16 * 16
35+ th = tw / 16 * 9
36+ }
37+ if tw == 0 || th == 0 {
38+ return nil
39+ }
40+ xs, ys := tileStarts(width, tw), tileStarts(height, th)
41+ if len(xs)*len(ys) > maxRegions {
42+ return nil
43+ }
44+ out := make([]region, 0, len(xs)*len(ys))
45+ for _, y := range ys {
46+ for _, x := range xs {
47+ out = append(out, region{x, y, tw, th})
48+ }
49+ }
50+ return out
51+}
52+
53+// tileStarts returns the start offsets of tiles of size t covering length l,
54+// evenly spread, the last one flush with the end, with consecutive tiles
55+// overlapping by at least a tenth of t.
56+func tileStarts(l, t int) []int {
57+ if l <= t {
58+ return []int{0}
59+ }
60+ d := l - t
61+ maxStep := t * 9 / 10
62+ n := (d+maxStep-1)/maxStep + 1
63+ out := make([]int, n)
64+ for i := range out {
65+ out[i] = (d*i + n - 2) / (n - 1) // ceil(d*i/(n-1)): steps never exceed maxStep
66+ }
67+ return out
68+}
69+
70+// dropCutBoxes drops the boxes that a tile's inner edge cut (an edge of the
71+// tile that is not an edge of the frame) when another tile saw the same
72+// element uncut: the cut box would otherwise survive as a smaller box nested
73+// in the whole one. A box is "the same element" when the other box covers
74+// nestRatio of it. Without regions the boxes are returned as they are.
75+func dropCutBoxes(dets []detection, regions []region) []detection {
76+ if len(regions) == 0 {
77+ return dets
78+ }
79+ fw, fh := 0, 0
80+ for _, r := range regions {
81+ fw, fh = max(fw, r.x+r.w), max(fh, r.y+r.h)
82+ }
83+ cut := func(d detection) bool {
84+ if d.region < 0 || d.region >= len(regions) {
85+ return false
86+ }
87+ r := regions[d.region]
88+ m := float32(tileCutMargin)
89+ return (r.x > 0 && d.x1 < float32(r.x)+m) || (r.x+r.w < fw && d.x2 > float32(r.x+r.w)-m) ||
90+ (r.y > 0 && d.y1 < float32(r.y)+m) || (r.y+r.h < fh && d.y2 > float32(r.y+r.h)-m)
91+ }
92+ isCut := make([]bool, len(dets))
93+ for i, d := range dets {
94+ isCut[i] = cut(d)
95+ }
96+ out := make([]detection, 0, len(dets))
97+ for i, d := range dets {
98+ drop := false
99+ if isCut[i] {
100+ for j, o := range dets {
101+ if j != i && !isCut[j] && o.region != d.region && covered(d, o) {
102+ drop = true
103+ break
104+ }
105+ }
106+ }
107+ if !drop {
108+ out = append(out, d)
109+ }
110+ }
111+ return out
112+}
113+
114+// covered reports whether o covers at least nestRatio of d's area.
115+func covered(d, o detection) bool {
116+ w := min(d.x2, o.x2) - max(d.x1, o.x1)
117+ h := min(d.y2, o.y2) - max(d.y1, o.y1)
118+ if w <= 0 || h <= 0 {
119+ return false
120+ }
121+ return float64(w*h) >= nestRatio*float64((d.x2-d.x1)*(d.y2-d.y1))
122+}
@@ -0,0 +1,122 @@
1+package hints
2+
3+const (
4+ // maxRegions is the protocol limit of regions per request.
5+ maxRegions = 64
6+ // wholeMaxSide is the longest frame side sent whole when the frame is not
7+ // 16:9: up to there the square letterbox of the detector loses little.
8+ wholeMaxSide = 2560
9+ // tileMaxH is the tallest tile; its width is 16/9 of it.
10+ tileMaxH = 1440
11+ // tileCutMargin is the distance, in physical pixels, to a tile's inner
12+ // edge under which a box counts as cut by it.
13+ tileCutMargin = 2
14+)
15+
16+// region is a crop of the frame, in physical pixels.
17+type region struct{ x, y, w, h int }
18+
19+// tileRegions returns the crops to detect separately on a width x height
20+// frame, or nil for one whole-frame detection (a 16:9 frame, or one whose
21+// long side is at most wholeMaxSide). Otherwise the frame is covered by exact
22+// 16:9 tiles (w*9 == h*16: the detector then uses its rectangular graph) of
23+// height min(height, 1440) rounded down to a multiple of 9, overlapping by at
24+// least 10 % of the tile side on each axis; the last tile of a row or column
25+// is aligned to the frame edge. Tiles are listed row by row. A frame that
26+// would need more than maxRegions tiles is detected whole.
27+func tileRegions(width, height int) []region {
28+ if width <= 0 || height <= 0 || width*9 == height*16 || max(width, height) <= wholeMaxSide {
29+ return nil
30+ }
31+ th := min(height, tileMaxH) / 9 * 9
32+ tw := th / 9 * 16
33+ if tw > width { // a tall frame: the width bounds the tile
34+ tw = width / 16 * 16
35+ th = tw / 16 * 9
36+ }
37+ if tw == 0 || th == 0 {
38+ return nil
39+ }
40+ xs, ys := tileStarts(width, tw), tileStarts(height, th)
41+ if len(xs)*len(ys) > maxRegions {
42+ return nil
43+ }
44+ out := make([]region, 0, len(xs)*len(ys))
45+ for _, y := range ys {
46+ for _, x := range xs {
47+ out = append(out, region{x, y, tw, th})
48+ }
49+ }
50+ return out
51+}
52+
53+// tileStarts returns the start offsets of tiles of size t covering length l,
54+// evenly spread, the last one flush with the end, with consecutive tiles
55+// overlapping by at least a tenth of t.
56+func tileStarts(l, t int) []int {
57+ if l <= t {
58+ return []int{0}
59+ }
60+ d := l - t
61+ maxStep := t * 9 / 10
62+ n := (d+maxStep-1)/maxStep + 1
63+ out := make([]int, n)
64+ for i := range out {
65+ out[i] = (d*i + n - 2) / (n - 1) // ceil(d*i/(n-1)): steps never exceed maxStep
66+ }
67+ return out
68+}
69+
70+// dropCutBoxes drops the boxes that a tile's inner edge cut (an edge of the
71+// tile that is not an edge of the frame) when another tile saw the same
72+// element uncut: the cut box would otherwise survive as a smaller box nested
73+// in the whole one. A box is "the same element" when the other box covers
74+// nestRatio of it. Without regions the boxes are returned as they are.
75+func dropCutBoxes(dets []detection, regions []region) []detection {
76+ if len(regions) == 0 {
77+ return dets
78+ }
79+ fw, fh := 0, 0
80+ for _, r := range regions {
81+ fw, fh = max(fw, r.x+r.w), max(fh, r.y+r.h)
82+ }
83+ cut := func(d detection) bool {
84+ if d.region < 0 || d.region >= len(regions) {
85+ return false
86+ }
87+ r := regions[d.region]
88+ m := float32(tileCutMargin)
89+ return (r.x > 0 && d.x1 < float32(r.x)+m) || (r.x+r.w < fw && d.x2 > float32(r.x+r.w)-m) ||
90+ (r.y > 0 && d.y1 < float32(r.y)+m) || (r.y+r.h < fh && d.y2 > float32(r.y+r.h)-m)
91+ }
92+ isCut := make([]bool, len(dets))
93+ for i, d := range dets {
94+ isCut[i] = cut(d)
95+ }
96+ out := make([]detection, 0, len(dets))
97+ for i, d := range dets {
98+ drop := false
99+ if isCut[i] {
100+ for j, o := range dets {
101+ if j != i && !isCut[j] && o.region != d.region && covered(d, o) {
102+ drop = true
103+ break
104+ }
105+ }
106+ }
107+ if !drop {
108+ out = append(out, d)
109+ }
110+ }
111+ return out
112+}
113+
114+// covered reports whether o covers at least nestRatio of d's area.
115+func covered(d, o detection) bool {
116+ w := min(d.x2, o.x2) - max(d.x1, o.x1)
117+ h := min(d.y2, o.y2) - max(d.y1, o.y1)
118+ if w <= 0 || h <= 0 {
119+ return false
120+ }
121+ return float64(w*h) >= nestRatio*float64((d.x2-d.x1)*(d.y2-d.y1))
122+}
A internal/adapters/hints/tile_test.go +75 -0
@@ -0,0 +1,75 @@
1+package hints
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestTileRegions(t *testing.T) {
10+ whole := [][2]int{{3840, 2160}, {1920, 1080}, {2560, 1080}, {2560, 1440}, {1280, 720}}
11+ for _, s := range whole {
12+ require.Nil(t, tileRegions(s[0], s[1]), "%dx%d", s[0], s[1])
13+ }
14+ require.Equal(t, []region{{0, 0, 2560, 1440}, {1280, 0, 2560, 1440}, {2560, 0, 2560, 1440},
15+ {0, 720, 2560, 1440}, {1280, 720, 2560, 1440}, {2560, 720, 2560, 1440}}, tileRegions(5120, 2160))
16+ require.Equal(t, []region{{0, 0, 2560, 1440}, {880, 0, 2560, 1440}}, tileRegions(3440, 1440))
17+
18+ for _, s := range [][2]int{{5120, 2160}, {3440, 1440}, {5120, 1440}, {7680, 2160}, {3000, 2999}, {1000, 3000}, {5121, 2161}, {6000, 1001}} {
19+ w, h := s[0], s[1]
20+ rs := tileRegions(w, h)
21+ require.NotEmpty(t, rs, "%dx%d", w, h)
22+ require.LessOrEqual(t, len(rs), maxRegions)
23+ covered := make([]bool, w*h)
24+ for _, r := range rs {
25+ require.Equal(t, r.h*16, r.w*9, "%dx%d tile %+v is not 16:9", w, h, r)
26+ require.GreaterOrEqual(t, r.x, 0)
27+ require.GreaterOrEqual(t, r.y, 0)
28+ require.LessOrEqual(t, r.x+r.w, w)
29+ require.LessOrEqual(t, r.y+r.h, h)
30+ for y := r.y; y < r.y+r.h; y++ {
31+ for x := r.x; x < r.x+r.w; x++ {
32+ covered[y*w+x] = true
33+ }
34+ }
35+ }
36+ for i, c := range covered {
37+ if !c {
38+ t.Fatalf("%dx%d: pixel %d,%d not covered", w, h, i%w, i/w)
39+ }
40+ }
41+ }
42+}
43+
44+func TestTileStartsOverlap(t *testing.T) {
45+ for _, l := range []int{2561, 3440, 5120, 7680, 10000} {
46+ xs := tileStarts(l, 2560)
47+ require.Equal(t, 0, xs[0])
48+ require.Equal(t, l-2560, xs[len(xs)-1])
49+ for i := 1; i < len(xs); i++ {
50+ require.GreaterOrEqual(t, xs[i-1]+2560-xs[i], 256, "length %d", l) // >= 10 %
51+ }
52+ }
53+}
54+
55+func TestTileRegionsTooMany(t *testing.T) {
56+ require.Nil(t, tileRegions(60000, 20000))
57+}
58+
59+func TestDropCutBoxes(t *testing.T) {
60+ rs := tileRegions(5120, 2160) // tile 0: x 0..2560, tile 1: x 1280..3840
61+ // A button at x 2500..2700 is cut at 2560 by tile 0 and whole in tile 1.
62+ cutBox := detection{x1: 2500, y1: 100, x2: 2560, y2: 140, score: 0.6, region: 0}
63+ whole := detection{x1: 2500, y1: 100, x2: 2700, y2: 140, score: 0.8, region: 1}
64+ require.Equal(t, []detection{whole}, dropCutBoxes([]detection{cutBox, whole}, rs))
65+ // Nobody saw it whole: the cut box stays.
66+ require.Equal(t, []detection{cutBox}, dropCutBoxes([]detection{cutBox}, rs))
67+ // An edge of the frame is not an inner edge.
68+ edge := detection{x1: 0, y1: 0, x2: 60, y2: 30, score: 0.6, region: 0}
69+ // Region 3 starts at y 720: its box at the frame's left edge is not cut
70+ // either, so both stay though they sit in different regions.
71+ other := detection{x1: 0, y1: 800, x2: 60, y2: 830, score: 0.5, region: 3}
72+ require.Equal(t, []detection{edge, other}, dropCutBoxes([]detection{edge, other}, rs))
73+ // No regions: unchanged.
74+ require.Equal(t, []detection{cutBox}, dropCutBoxes([]detection{cutBox}, nil))
75+}
@@ -0,0 +1,75 @@
1+package hints
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestTileRegions(t *testing.T) {
10+ whole := [][2]int{{3840, 2160}, {1920, 1080}, {2560, 1080}, {2560, 1440}, {1280, 720}}
11+ for _, s := range whole {
12+ require.Nil(t, tileRegions(s[0], s[1]), "%dx%d", s[0], s[1])
13+ }
14+ require.Equal(t, []region{{0, 0, 2560, 1440}, {1280, 0, 2560, 1440}, {2560, 0, 2560, 1440},
15+ {0, 720, 2560, 1440}, {1280, 720, 2560, 1440}, {2560, 720, 2560, 1440}}, tileRegions(5120, 2160))
16+ require.Equal(t, []region{{0, 0, 2560, 1440}, {880, 0, 2560, 1440}}, tileRegions(3440, 1440))
17+
18+ for _, s := range [][2]int{{5120, 2160}, {3440, 1440}, {5120, 1440}, {7680, 2160}, {3000, 2999}, {1000, 3000}, {5121, 2161}, {6000, 1001}} {
19+ w, h := s[0], s[1]
20+ rs := tileRegions(w, h)
21+ require.NotEmpty(t, rs, "%dx%d", w, h)
22+ require.LessOrEqual(t, len(rs), maxRegions)
23+ covered := make([]bool, w*h)
24+ for _, r := range rs {
25+ require.Equal(t, r.h*16, r.w*9, "%dx%d tile %+v is not 16:9", w, h, r)
26+ require.GreaterOrEqual(t, r.x, 0)
27+ require.GreaterOrEqual(t, r.y, 0)
28+ require.LessOrEqual(t, r.x+r.w, w)
29+ require.LessOrEqual(t, r.y+r.h, h)
30+ for y := r.y; y < r.y+r.h; y++ {
31+ for x := r.x; x < r.x+r.w; x++ {
32+ covered[y*w+x] = true
33+ }
34+ }
35+ }
36+ for i, c := range covered {
37+ if !c {
38+ t.Fatalf("%dx%d: pixel %d,%d not covered", w, h, i%w, i/w)
39+ }
40+ }
41+ }
42+}
43+
44+func TestTileStartsOverlap(t *testing.T) {
45+ for _, l := range []int{2561, 3440, 5120, 7680, 10000} {
46+ xs := tileStarts(l, 2560)
47+ require.Equal(t, 0, xs[0])
48+ require.Equal(t, l-2560, xs[len(xs)-1])
49+ for i := 1; i < len(xs); i++ {
50+ require.GreaterOrEqual(t, xs[i-1]+2560-xs[i], 256, "length %d", l) // >= 10 %
51+ }
52+ }
53+}
54+
55+func TestTileRegionsTooMany(t *testing.T) {
56+ require.Nil(t, tileRegions(60000, 20000))
57+}
58+
59+func TestDropCutBoxes(t *testing.T) {
60+ rs := tileRegions(5120, 2160) // tile 0: x 0..2560, tile 1: x 1280..3840
61+ // A button at x 2500..2700 is cut at 2560 by tile 0 and whole in tile 1.
62+ cutBox := detection{x1: 2500, y1: 100, x2: 2560, y2: 140, score: 0.6, region: 0}
63+ whole := detection{x1: 2500, y1: 100, x2: 2700, y2: 140, score: 0.8, region: 1}
64+ require.Equal(t, []detection{whole}, dropCutBoxes([]detection{cutBox, whole}, rs))
65+ // Nobody saw it whole: the cut box stays.
66+ require.Equal(t, []detection{cutBox}, dropCutBoxes([]detection{cutBox}, rs))
67+ // An edge of the frame is not an inner edge.
68+ edge := detection{x1: 0, y1: 0, x2: 60, y2: 30, score: 0.6, region: 0}
69+ // Region 3 starts at y 720: its box at the frame's left edge is not cut
70+ // either, so both stay though they sit in different regions.
71+ other := detection{x1: 0, y1: 800, x2: 60, y2: 830, score: 0.5, region: 3}
72+ require.Equal(t, []detection{edge, other}, dropCutBoxes([]detection{edge, other}, rs))
73+ // No regions: unchanged.
74+ require.Equal(t, []detection{cutBox}, dropCutBoxes([]detection{cutBox}, nil))
75+}

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 26

Tags 10

↑↓ moveenter openesc close