fix(hints): bound detector work and account for clamped labels
- commit
- 54fa09059eb686468fd8420342c1a15bff6eb5de
- author
- brice · · 2026-10-22 05:39 UTC
- parent
- 08d5eeb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +228 -12
M internal/adapters/hints/detector.go +5 -2
| @@ -57,7 +57,7 @@ const ( | |||
| 57 | 57 | // maxString and maxBoxes bound what a misbehaving child can make us | |
| 58 | 58 | // allocate. | |
| 59 | 59 | maxString = 1 << 16 | |
| 60 | - | maxBoxes = 1 << 20 | |
| 60 | + | maxBoxes = 64 * 1000 // runner default max-det per region, at most 64 regions | |
| 61 | 61 | ) | |
| 62 | 62 | ||
| 63 | 63 | var ( | |
| @@ -318,7 +318,9 @@ func (d *procDetector) start(ctx context.Context) error { | |||
| 318 | 318 | timedOut := !timer.Stop() | |
| 319 | 319 | if err != nil { | |
| 320 | 320 | p.kill() | |
| 321 | - | if timedOut { | |
| 321 | + | if ctx.Err() != nil { | |
| 322 | + | err = ctx.Err() | |
| 323 | + | } else if timedOut { | |
| 322 | 324 | err = errors.New("hello timed out") | |
| 323 | 325 | } | |
| 324 | 326 | return fmt.Errorf("hints: detector hello: %w", err) | |
| @@ -384,6 +386,7 @@ func (d *procDetector) Close() error { | |||
| 384 | 386 | if p == nil { | |
| 385 | 387 | return nil | |
| 386 | 388 | } | |
| 389 | + | defer func() { _ = p.out.Close() }() | |
| 387 | 390 | _ = p.in.Close() | |
| 388 | 391 | done := make(chan struct{}) | |
| 389 | 392 | go func() { p.wait(); close(done) }() | |
| @@ -57,7 +57,7 @@ const ( | |||||
| 57 | // maxString and maxBoxes bound what a misbehaving child can make us | 57 | // maxString and maxBoxes bound what a misbehaving child can make us | ||
| 58 | // allocate. | 58 | // allocate. | ||
| 59 | maxString = 1 << 16 | 59 | maxString = 1 << 16 | ||
| 60 | - | maxBoxes = 1 << 20 | 60 | + | maxBoxes = 64 * 1000 // runner default max-det per region, at most 64 regions |
| 61 | ) | 61 | ) | ||
| 62 | 62 | ||||
| 63 | var ( | 63 | var ( | ||
| @@ -318,7 +318,9 @@ func (d *procDetector) start(ctx context.Context) error { | |||||
| 318 | timedOut := !timer.Stop() | 318 | timedOut := !timer.Stop() | ||
| 319 | if err != nil { | 319 | if err != nil { | ||
| 320 | p.kill() | 320 | p.kill() | ||
| 321 | - | if timedOut { | 321 | + | if ctx.Err() != nil { |
| 322 | + | err = ctx.Err() | |||
| 323 | + | } else if timedOut { | |||
| 322 | err = errors.New("hello timed out") | 324 | err = errors.New("hello timed out") | ||
| 323 | } | 325 | } | ||
| 324 | return fmt.Errorf("hints: detector hello: %w", err) | 326 | return fmt.Errorf("hints: detector hello: %w", err) | ||
| @@ -384,6 +386,7 @@ func (d *procDetector) Close() error { | |||||
| 384 | if p == nil { | 386 | if p == nil { | ||
| 385 | return nil | 387 | return nil | ||
| 386 | } | 388 | } | ||
| 389 | + | defer func() { _ = p.out.Close() }() | |||
| 387 | _ = p.in.Close() | 390 | _ = p.in.Close() | ||
| 388 | done := make(chan struct{}) | 391 | done := make(chan struct{}) | ||
| 389 | go func() { p.wait(); close(done) }() | 392 | go func() { p.wait(); close(done) }() | ||
M internal/adapters/hints/detector_test.go +115 -0
| @@ -4,8 +4,12 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/binary" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 7 | 9 | "math" | |
| 10 | + | "net" | |
| 8 | 11 | "os" | |
| 12 | + | "path/filepath" | |
| 9 | 13 | "testing" | |
| 10 | 14 | "time" | |
| 11 | 15 | ||
| @@ -156,3 +160,114 @@ func TestProcDetectorCommand(t *testing.T) { | |||
| 156 | 160 | require.Equal(t, want, d.cmd().Args) | |
| 157 | 161 | } | |
| 158 | 162 | } | |
| 163 | + | ||
| 164 | + | func TestResponseBoxLimit(t *testing.T) { | |
| 165 | + | _, err := readResponse(bytes.NewReader(cat([]byte("NHRS"), u32(1), u64(1), u32(0), f32(0), u32(64001))), 1) | |
| 166 | + | require.ErrorIs(t, err, errProtocol) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestPrepareDetections(t *testing.T) { | |
| 170 | + | regions := []region{{0, 0, 100, 100}, {50, 0, 100, 100}} | |
| 171 | + | valid := detection{x1: 90, y1: 10, x2: 100, y2: 30, score: .5} | |
| 172 | + | invalid := detection{x1: 80, y1: 0, x2: 120, y2: 40, score: float32(math.NaN()), region: 1} | |
| 173 | + | badRegion := valid | |
| 174 | + | badRegion.region = 2 | |
| 175 | + | got := prepareDetections([]detection{valid, invalid, badRegion}, regions, 150, 100) | |
| 176 | + | require.Equal(t, []detection{valid}, dropCutBoxes(got, regions)) | |
| 177 | + | many := make([]detection, maxWorkBoxes+5) | |
| 178 | + | for i := range many { | |
| 179 | + | many[i] = valid | |
| 180 | + | many[i].score = float32(i) / float32(len(many)) | |
| 181 | + | } | |
| 182 | + | got = prepareDetections(many, nil, 150, 100) | |
| 183 | + | require.Len(t, got, maxWorkBoxes) | |
| 184 | + | require.Equal(t, many[len(many)-1].score, got[0].score) | |
| 185 | + | require.Equal(t, many[5].score, got[len(got)-1].score) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // TestDetectorChild is a real subprocess speaking just enough protocol to | |
| 189 | + | // synchronize cancellation without timers or project-interface doubles. | |
| 190 | + | func TestDetectorChild(t *testing.T) { | |
| 191 | + | mode := os.Getenv("HINT_CHILD_MODE") | |
| 192 | + | if mode == "" { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | conn, err := net.Dial("unix", os.Getenv("HINT_CHILD_SOCKET")) | |
| 196 | + | if err != nil { | |
| 197 | + | os.Exit(2) | |
| 198 | + | } | |
| 199 | + | defer conn.Close() | |
| 200 | + | if mode != "hello" { | |
| 201 | + | _, _ = os.Stdout.Write(cat([]byte("NHNT"), u32(1), u32(0), u32(3), []byte("cpu"), u32(1280))) | |
| 202 | + | } | |
| 203 | + | if mode == "request" { | |
| 204 | + | b := make([]byte, 44) // request header, confidence and one BGRA pixel | |
| 205 | + | if _, err := io.ReadFull(os.Stdin, b); err != nil { | |
| 206 | + | os.Exit(3) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | _, _ = conn.Write([]byte{1}) | |
| 210 | + | _, _ = io.Copy(io.Discard, os.Stdin) | |
| 211 | + | os.Exit(0) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func childDetector(t *testing.T, mode string) (*procDetector, net.Listener) { | |
| 215 | + | t.Helper() | |
| 216 | + | dir := t.TempDir() | |
| 217 | + | socket := filepath.Join(dir, "ready") | |
| 218 | + | listener, err := net.Listen("unix", socket) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | t.Cleanup(func() { _ = listener.Close() }) | |
| 221 | + | t.Setenv("HINT_CHILD_MODE", mode) | |
| 222 | + | t.Setenv("HINT_CHILD_SOCKET", socket) | |
| 223 | + | exe, err := os.Executable() | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | script := filepath.Join(dir, "detector") | |
| 226 | + | require.NoError(t, os.WriteFile(script, fmt.Appendf(nil, "#!/bin/sh\nexec %q -test.run=^TestDetectorChild$\n", exe), 0700)) | |
| 227 | + | d := newProcDetector(script, dir, 0) | |
| 228 | + | t.Cleanup(func() { _ = d.Close() }) | |
| 229 | + | return d, listener | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestDetectorCancellation(t *testing.T) { | |
| 233 | + | for _, mode := range []string{"hello", "request"} { | |
| 234 | + | t.Run(mode, func(t *testing.T) { | |
| 235 | + | d, l := childDetector(t, mode) | |
| 236 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 237 | + | defer cancel() | |
| 238 | + | done := make(chan error, 1) | |
| 239 | + | go func() { | |
| 240 | + | _, err := d.Detect(ctx, detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4)}}) | |
| 241 | + | done <- err | |
| 242 | + | }() | |
| 243 | + | require.NoError(t, l.(*net.UnixListener).SetDeadline(time.Now().Add(5*time.Second))) | |
| 244 | + | conn, err := l.Accept() | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) | |
| 247 | + | var ready [1]byte | |
| 248 | + | _, err = io.ReadFull(conn, ready[:]) | |
| 249 | + | _ = conn.Close() | |
| 250 | + | require.NoError(t, err) | |
| 251 | + | cancel() | |
| 252 | + | select { | |
| 253 | + | case err := <-done: | |
| 254 | + | require.ErrorIs(t, err, context.Canceled) | |
| 255 | + | case <-time.After(5 * time.Second): | |
| 256 | + | t.Fatal("cancellation did not finish") | |
| 257 | + | } | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestDetectorCloseClosesOutput(t *testing.T) { | |
| 263 | + | d, l := childDetector(t, "close") | |
| 264 | + | require.NoError(t, d.start(context.Background())) | |
| 265 | + | conn, err := l.Accept() | |
| 266 | + | require.NoError(t, err) | |
| 267 | + | _ = conn.Close() | |
| 268 | + | p := d.proc | |
| 269 | + | require.NoError(t, d.Close()) | |
| 270 | + | _, err = p.out.Read(make([]byte, 1)) | |
| 271 | + | require.ErrorIs(t, err, os.ErrClosed) | |
| 272 | + | require.NoError(t, d.Close()) | |
| 273 | + | } | |
| @@ -4,8 +4,12 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/binary" | 6 | "encoding/binary" | ||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 7 | "math" | 9 | "math" | ||
| 10 | + | "net" | |||
| 8 | "os" | 11 | "os" | ||
| 12 | + | "path/filepath" | |||
| 9 | "testing" | 13 | "testing" | ||
| 10 | "time" | 14 | "time" | ||
| 11 | 15 | ||||
| @@ -156,3 +160,114 @@ func TestProcDetectorCommand(t *testing.T) { | |||||
| 156 | require.Equal(t, want, d.cmd().Args) | 160 | require.Equal(t, want, d.cmd().Args) | ||
| 157 | } | 161 | } | ||
| 158 | } | 162 | } | ||
| 163 | + | ||||
| 164 | + | func TestResponseBoxLimit(t *testing.T) { | |||
| 165 | + | _, err := readResponse(bytes.NewReader(cat([]byte("NHRS"), u32(1), u64(1), u32(0), f32(0), u32(64001))), 1) | |||
| 166 | + | require.ErrorIs(t, err, errProtocol) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestPrepareDetections(t *testing.T) { | |||
| 170 | + | regions := []region{{0, 0, 100, 100}, {50, 0, 100, 100}} | |||
| 171 | + | valid := detection{x1: 90, y1: 10, x2: 100, y2: 30, score: .5} | |||
| 172 | + | invalid := detection{x1: 80, y1: 0, x2: 120, y2: 40, score: float32(math.NaN()), region: 1} | |||
| 173 | + | badRegion := valid | |||
| 174 | + | badRegion.region = 2 | |||
| 175 | + | got := prepareDetections([]detection{valid, invalid, badRegion}, regions, 150, 100) | |||
| 176 | + | require.Equal(t, []detection{valid}, dropCutBoxes(got, regions)) | |||
| 177 | + | many := make([]detection, maxWorkBoxes+5) | |||
| 178 | + | for i := range many { | |||
| 179 | + | many[i] = valid | |||
| 180 | + | many[i].score = float32(i) / float32(len(many)) | |||
| 181 | + | } | |||
| 182 | + | got = prepareDetections(many, nil, 150, 100) | |||
| 183 | + | require.Len(t, got, maxWorkBoxes) | |||
| 184 | + | require.Equal(t, many[len(many)-1].score, got[0].score) | |||
| 185 | + | require.Equal(t, many[5].score, got[len(got)-1].score) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // TestDetectorChild is a real subprocess speaking just enough protocol to | |||
| 189 | + | // synchronize cancellation without timers or project-interface doubles. | |||
| 190 | + | func TestDetectorChild(t *testing.T) { | |||
| 191 | + | mode := os.Getenv("HINT_CHILD_MODE") | |||
| 192 | + | if mode == "" { | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | conn, err := net.Dial("unix", os.Getenv("HINT_CHILD_SOCKET")) | |||
| 196 | + | if err != nil { | |||
| 197 | + | os.Exit(2) | |||
| 198 | + | } | |||
| 199 | + | defer conn.Close() | |||
| 200 | + | if mode != "hello" { | |||
| 201 | + | _, _ = os.Stdout.Write(cat([]byte("NHNT"), u32(1), u32(0), u32(3), []byte("cpu"), u32(1280))) | |||
| 202 | + | } | |||
| 203 | + | if mode == "request" { | |||
| 204 | + | b := make([]byte, 44) // request header, confidence and one BGRA pixel | |||
| 205 | + | if _, err := io.ReadFull(os.Stdin, b); err != nil { | |||
| 206 | + | os.Exit(3) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | _, _ = conn.Write([]byte{1}) | |||
| 210 | + | _, _ = io.Copy(io.Discard, os.Stdin) | |||
| 211 | + | os.Exit(0) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func childDetector(t *testing.T, mode string) (*procDetector, net.Listener) { | |||
| 215 | + | t.Helper() | |||
| 216 | + | dir := t.TempDir() | |||
| 217 | + | socket := filepath.Join(dir, "ready") | |||
| 218 | + | listener, err := net.Listen("unix", socket) | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | t.Cleanup(func() { _ = listener.Close() }) | |||
| 221 | + | t.Setenv("HINT_CHILD_MODE", mode) | |||
| 222 | + | t.Setenv("HINT_CHILD_SOCKET", socket) | |||
| 223 | + | exe, err := os.Executable() | |||
| 224 | + | require.NoError(t, err) | |||
| 225 | + | script := filepath.Join(dir, "detector") | |||
| 226 | + | require.NoError(t, os.WriteFile(script, fmt.Appendf(nil, "#!/bin/sh\nexec %q -test.run=^TestDetectorChild$\n", exe), 0700)) | |||
| 227 | + | d := newProcDetector(script, dir, 0) | |||
| 228 | + | t.Cleanup(func() { _ = d.Close() }) | |||
| 229 | + | return d, listener | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestDetectorCancellation(t *testing.T) { | |||
| 233 | + | for _, mode := range []string{"hello", "request"} { | |||
| 234 | + | t.Run(mode, func(t *testing.T) { | |||
| 235 | + | d, l := childDetector(t, mode) | |||
| 236 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 237 | + | defer cancel() | |||
| 238 | + | done := make(chan error, 1) | |||
| 239 | + | go func() { | |||
| 240 | + | _, err := d.Detect(ctx, detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4)}}) | |||
| 241 | + | done <- err | |||
| 242 | + | }() | |||
| 243 | + | require.NoError(t, l.(*net.UnixListener).SetDeadline(time.Now().Add(5*time.Second))) | |||
| 244 | + | conn, err := l.Accept() | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) | |||
| 247 | + | var ready [1]byte | |||
| 248 | + | _, err = io.ReadFull(conn, ready[:]) | |||
| 249 | + | _ = conn.Close() | |||
| 250 | + | require.NoError(t, err) | |||
| 251 | + | cancel() | |||
| 252 | + | select { | |||
| 253 | + | case err := <-done: | |||
| 254 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 255 | + | case <-time.After(5 * time.Second): | |||
| 256 | + | t.Fatal("cancellation did not finish") | |||
| 257 | + | } | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestDetectorCloseClosesOutput(t *testing.T) { | |||
| 263 | + | d, l := childDetector(t, "close") | |||
| 264 | + | require.NoError(t, d.start(context.Background())) | |||
| 265 | + | conn, err := l.Accept() | |||
| 266 | + | require.NoError(t, err) | |||
| 267 | + | _ = conn.Close() | |||
| 268 | + | p := d.proc | |||
| 269 | + | require.NoError(t, d.Close()) | |||
| 270 | + | _, err = p.out.Read(make([]byte, 1)) | |||
| 271 | + | require.ErrorIs(t, err, os.ErrClosed) | |||
| 272 | + | require.NoError(t, d.Close()) | |||
| 273 | + | } | |||
M internal/adapters/hints/hints.go +40 -0
| @@ -4,11 +4,13 @@ | |||
| 4 | 4 | package hints | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | + | "cmp" | |
| 7 | 8 | "context" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "image" | |
| 11 | 12 | "math" | |
| 13 | + | "slices" | |
| 12 | 14 | "time" | |
| 13 | 15 | ||
| 14 | 16 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| @@ -210,6 +212,9 @@ func (w *worker) reap(id uint64) { | |||
| 210 | 212 | // minTarget is the smallest target side kept, in logical pixels. | |
| 211 | 213 | const minTarget = 4 | |
| 212 | 214 | ||
| 215 | + | // maxWorkBoxes bounds the quadratic cut-box and merge passes. | |
| 216 | + | const maxWorkBoxes = 2000 | |
| 217 | + | ||
| 213 | 218 | // scan sends the frame to the detector and turns the boxes into targets. It | |
| 214 | 219 | // releases the frame, or hands it to the dump job. | |
| 215 | 220 | func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult { | |
| @@ -227,6 +232,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, | |||
| 227 | 232 | // Boxes cut by a tile's inner edge are dropped when a neighbour tile | |
| 228 | 233 | // saw the element whole; then, on the union of all tiles and after | |
| 229 | 234 | // rounding to logical pixels, duplicates and nested boxes are merged. | |
| 235 | + | dets = prepareDetections(dets, regions, req.Width, req.Height) | |
| 230 | 236 | res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height)) | |
| 231 | 237 | if w.dumpDir != "" { | |
| 232 | 238 | // The dump reads the frame after the result is sent and releases it. | |
| @@ -271,3 +277,37 @@ func targetsOf(dets []detection, scale float64, width, height int) []ports.HintT | |||
| 271 | 277 | } | |
| 272 | 278 | return out | |
| 273 | 279 | } | |
| 280 | + | ||
| 281 | + | // prepareDetections validates and clamps boxes before any pairwise work, | |
| 282 | + | // preserving their region metadata for cut-edge suppression. | |
| 283 | + | func prepareDetections(in []detection, regions []region, width, height int) []detection { | |
| 284 | + | out := make([]detection, 0, len(in)) | |
| 285 | + | for _, d := range in { | |
| 286 | + | if d.region < 0 || d.region >= max(1, len(regions)) || !(d.score >= 0 && d.score <= 1) { | |
| 287 | + | continue | |
| 288 | + | } | |
| 289 | + | valid := true | |
| 290 | + | for _, v := range []float32{d.x1, d.y1, d.x2, d.y2} { | |
| 291 | + | if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) { | |
| 292 | + | valid = false | |
| 293 | + | break | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | if !valid { | |
| 297 | + | continue | |
| 298 | + | } | |
| 299 | + | d.x1 = max(0, min(d.x1, float32(width))) | |
| 300 | + | d.x2 = max(0, min(d.x2, float32(width))) | |
| 301 | + | d.y1 = max(0, min(d.y1, float32(height))) | |
| 302 | + | d.y2 = max(0, min(d.y2, float32(height))) | |
| 303 | + | if d.x2 <= d.x1 || d.y2 <= d.y1 { | |
| 304 | + | continue | |
| 305 | + | } | |
| 306 | + | out = append(out, d) | |
| 307 | + | } | |
| 308 | + | slices.SortStableFunc(out, func(a, b detection) int { return cmp.Compare(b.score, a.score) }) | |
| 309 | + | if len(out) > maxWorkBoxes { | |
| 310 | + | out = out[:maxWorkBoxes] | |
| 311 | + | } | |
| 312 | + | return out | |
| 313 | + | } | |
| @@ -4,11 +4,13 @@ | |||||
| 4 | package hints | 4 | package hints | ||
| 5 | 5 | ||||
| 6 | import ( | 6 | import ( | ||
| 7 | + | "cmp" | |||
| 7 | "context" | 8 | "context" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "fmt" | 10 | "fmt" | ||
| 10 | "image" | 11 | "image" | ||
| 11 | "math" | 12 | "math" | ||
| 13 | + | "slices" | |||
| 12 | "time" | 14 | "time" | ||
| 13 | 15 | ||||
| 14 | "github.com/bnema/neferwl/internal/adapters/logging" | 16 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| @@ -210,6 +212,9 @@ func (w *worker) reap(id uint64) { | |||||
| 210 | // minTarget is the smallest target side kept, in logical pixels. | 212 | // minTarget is the smallest target side kept, in logical pixels. | ||
| 211 | const minTarget = 4 | 213 | const minTarget = 4 | ||
| 212 | 214 | ||||
| 215 | + | // maxWorkBoxes bounds the quadratic cut-box and merge passes. | |||
| 216 | + | const maxWorkBoxes = 2000 | |||
| 217 | + | ||||
| 213 | // scan sends the frame to the detector and turns the boxes into targets. It | 218 | // scan sends the frame to the detector and turns the boxes into targets. It | ||
| 214 | // releases the frame, or hands it to the dump job. | 219 | // releases the frame, or hands it to the dump job. | ||
| 215 | func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult { | 220 | func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, captureDur time.Duration, res ports.HintScanResult) ports.HintScanResult { | ||
| @@ -227,6 +232,7 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, fr frame, | |||||
| 227 | // Boxes cut by a tile's inner edge are dropped when a neighbour tile | 232 | // 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 | 233 | // saw the element whole; then, on the union of all tiles and after | ||
| 229 | // rounding to logical pixels, duplicates and nested boxes are merged. | 234 | // rounding to logical pixels, duplicates and nested boxes are merged. | ||
| 235 | + | dets = prepareDetections(dets, regions, req.Width, req.Height) | |||
| 230 | res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height)) | 236 | res.Targets = mergeTargets(targetsOf(dropCutBoxes(dets, regions), req.Scale, req.Width, req.Height)) | ||
| 231 | if w.dumpDir != "" { | 237 | if w.dumpDir != "" { | ||
| 232 | // The dump reads the frame after the result is sent and releases it. | 238 | // The dump reads the frame after the result is sent and releases it. | ||
| @@ -271,3 +277,37 @@ func targetsOf(dets []detection, scale float64, width, height int) []ports.HintT | |||||
| 271 | } | 277 | } | ||
| 272 | return out | 278 | return out | ||
| 273 | } | 279 | } | ||
| 280 | + | ||||
| 281 | + | // prepareDetections validates and clamps boxes before any pairwise work, | |||
| 282 | + | // preserving their region metadata for cut-edge suppression. | |||
| 283 | + | func prepareDetections(in []detection, regions []region, width, height int) []detection { | |||
| 284 | + | out := make([]detection, 0, len(in)) | |||
| 285 | + | for _, d := range in { | |||
| 286 | + | if d.region < 0 || d.region >= max(1, len(regions)) || !(d.score >= 0 && d.score <= 1) { | |||
| 287 | + | continue | |||
| 288 | + | } | |||
| 289 | + | valid := true | |||
| 290 | + | for _, v := range []float32{d.x1, d.y1, d.x2, d.y2} { | |||
| 291 | + | if math.IsNaN(float64(v)) || math.IsInf(float64(v), 0) { | |||
| 292 | + | valid = false | |||
| 293 | + | break | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | if !valid { | |||
| 297 | + | continue | |||
| 298 | + | } | |||
| 299 | + | d.x1 = max(0, min(d.x1, float32(width))) | |||
| 300 | + | d.x2 = max(0, min(d.x2, float32(width))) | |||
| 301 | + | d.y1 = max(0, min(d.y1, float32(height))) | |||
| 302 | + | d.y2 = max(0, min(d.y2, float32(height))) | |||
| 303 | + | if d.x2 <= d.x1 || d.y2 <= d.y1 { | |||
| 304 | + | continue | |||
| 305 | + | } | |||
| 306 | + | out = append(out, d) | |||
| 307 | + | } | |||
| 308 | + | slices.SortStableFunc(out, func(a, b detection) int { return cmp.Compare(b.score, a.score) }) | |||
| 309 | + | if len(out) > maxWorkBoxes { | |||
| 310 | + | out = out[:maxWorkBoxes] | |||
| 311 | + | } | |||
| 312 | + | return out | |||
| 313 | + | } | |||
M internal/adapters/hints/merge.go +1 -1
| @@ -51,7 +51,7 @@ func mergeTargets(in []ports.HintTarget) []ports.HintTarget { | |||
| 51 | 51 | return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X), | |
| 52 | 52 | cmp.Compare(area(a.Rect), area(b.Rect))) | |
| 53 | 53 | }) | |
| 54 | - | kept := s[:0:0] | |
| 54 | + | kept := s[:0] | |
| 55 | 55 | for _, t := range s { | |
| 56 | 56 | dup := false | |
| 57 | 57 | for _, k := range kept { | |
| @@ -51,7 +51,7 @@ func mergeTargets(in []ports.HintTarget) []ports.HintTarget { | |||||
| 51 | return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X), | 51 | return cmp.Or(cmp.Compare(b.Score, a.Score), cmp.Compare(a.Rect.Y, b.Rect.Y), cmp.Compare(a.Rect.X, b.Rect.X), | ||
| 52 | cmp.Compare(area(a.Rect), area(b.Rect))) | 52 | cmp.Compare(area(a.Rect), area(b.Rect))) | ||
| 53 | }) | 53 | }) | ||
| 54 | - | kept := s[:0:0] | 54 | + | kept := s[:0] |
| 55 | for _, t := range s { | 55 | for _, t := range s { | ||
| 56 | dup := false | 56 | dup := false | ||
| 57 | for _, k := range kept { | 57 | for _, k := range kept { | ||
M internal/adapters/hints/tile.go +1 -1
| @@ -19,7 +19,7 @@ type region struct{ x, y, w, h int } | |||
| 19 | 19 | // tileRegions returns the crops to detect separately on a width x height | |
| 20 | 20 | // frame, or nil for one whole-frame detection (a 16:9 frame, or one whose | |
| 21 | 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 | |
| 22 | + | // 16:9 tiles (w*9 == h*16; explicit crops always use square letterboxing) of | |
| 23 | 23 | // height min(height, 1440) rounded down to a multiple of 9, overlapping by at | |
| 24 | 24 | // least 10 % of the tile side on each axis; the last tile of a row or column | |
| 25 | 25 | // is aligned to the frame edge. Tiles are listed row by row. A frame that | |
| @@ -19,7 +19,7 @@ type region struct{ x, y, w, h int } | |||||
| 19 | // tileRegions returns the crops to detect separately on a width x height | 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 | 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 | 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 | 22 | + | // 16:9 tiles (w*9 == h*16; explicit crops always use square letterboxing) of |
| 23 | // height min(height, 1440) rounded down to a multiple of 9, overlapping by at | 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 | 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 | 25 | // is aligned to the frame edge. Tiles are listed row by row. A frame that | ||
M internal/core/hint.go +21 -8
| @@ -216,25 +216,38 @@ func (c *Core) hintLabelSize(sc *screen) (w, h int) { | |||
| 216 | 216 | } | |
| 217 | 217 | font = px / scale | |
| 218 | 218 | } | |
| 219 | - | return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4)) | |
| 219 | + | font = math.Min(font, 256/scale) | |
| 220 | + | s := font / 13 | |
| 221 | + | return int(math.Ceil(2*0.6*font + 6*s + 2/scale + 1)), int(math.Ceil(1.25*font + 2*s + 2/scale + 1)) | |
| 220 | 222 | } | |
| 221 | 223 | ||
| 222 | - | // hintSpread drops the targets whose labels would overlap another label. A | |
| 223 | - | // label hangs off its target's top-left corner, so two labels overlap when | |
| 224 | - | // the corners are closer than the label size on both axes. Targets are | |
| 225 | - | // considered by descending score (then top-left to bottom-right): the first | |
| 226 | - | // keeps its place, a later one that collides with a kept one is dropped. | |
| 224 | + | // hintLabelRect mirrors the renderer's outside top-left anchor and output | |
| 225 | + | // clamping, in output-local logical coordinates. | |
| 226 | + | func hintLabelRect(sc *screen, target ports.Rect, lw, lh int) ports.Rect { | |
| 227 | + | bounds := sc.rect() | |
| 228 | + | inset := int(math.Round(0.25 * float64(lh))) | |
| 229 | + | return ports.Rect{X: min(max(target.X-lw+inset, 0), bounds.W-lw), Y: min(max(target.Y-lh+inset, 0), bounds.H-lh), W: lw, H: lh} | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // hintSpread drops targets whose output-clamped label rectangles intersect. | |
| 233 | + | // Highest scores win; four times the label budget gives spreading headroom | |
| 234 | + | // while bounding the quadratic pass. hintlabel.FontPixels is the source of | |
| 235 | + | // truth for the font rule mirrored above. | |
| 227 | 236 | func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget { | |
| 228 | 237 | lw, lh := c.hintLabelSize(sc) | |
| 229 | 238 | order := slices.Clone(ts) | |
| 230 | 239 | slices.SortStableFunc(order, func(a, b hintTarget) int { | |
| 231 | 240 | return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | |
| 232 | 241 | }) | |
| 233 | - | kept := order[:0:0] | |
| 242 | + | if len(order) > 4*hintMaxTargets { | |
| 243 | + | order = order[:4*hintMaxTargets] | |
| 244 | + | } | |
| 245 | + | kept := order[:0] | |
| 234 | 246 | for _, t := range order { | |
| 235 | 247 | clash := false | |
| 236 | 248 | for _, k := range kept { | |
| 237 | - | if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh { | |
| 249 | + | a, b := hintLabelRect(sc, t.rect, lw, lh), hintLabelRect(sc, k.rect, lw, lh) | |
| 250 | + | if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { | |
| 238 | 251 | clash = true | |
| 239 | 252 | break | |
| 240 | 253 | } | |
| @@ -216,25 +216,38 @@ func (c *Core) hintLabelSize(sc *screen) (w, h int) { | |||||
| 216 | } | 216 | } | ||
| 217 | font = px / scale | 217 | font = px / scale | ||
| 218 | } | 218 | } | ||
| 219 | - | return int(math.Ceil(2*0.6*font + 8)), int(math.Ceil(1.25*font + 4)) | 219 | + | font = math.Min(font, 256/scale) |
| 220 | + | s := font / 13 | |||
| 221 | + | return int(math.Ceil(2*0.6*font + 6*s + 2/scale + 1)), int(math.Ceil(1.25*font + 2*s + 2/scale + 1)) | |||
| 220 | } | 222 | } | ||
| 221 | 223 | ||||
| 222 | - | // hintSpread drops the targets whose labels would overlap another label. A | 224 | + | // hintLabelRect mirrors the renderer's outside top-left anchor and output |
| 223 | - | // label hangs off its target's top-left corner, so two labels overlap when | 225 | + | // clamping, in output-local logical coordinates. |
| 224 | - | // the corners are closer than the label size on both axes. Targets are | 226 | + | func hintLabelRect(sc *screen, target ports.Rect, lw, lh int) ports.Rect { |
| 225 | - | // considered by descending score (then top-left to bottom-right): the first | 227 | + | bounds := sc.rect() |
| 226 | - | // keeps its place, a later one that collides with a kept one is dropped. | 228 | + | inset := int(math.Round(0.25 * float64(lh))) |
| 229 | + | return ports.Rect{X: min(max(target.X-lw+inset, 0), bounds.W-lw), Y: min(max(target.Y-lh+inset, 0), bounds.H-lh), W: lw, H: lh} | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // hintSpread drops targets whose output-clamped label rectangles intersect. | |||
| 233 | + | // Highest scores win; four times the label budget gives spreading headroom | |||
| 234 | + | // while bounding the quadratic pass. hintlabel.FontPixels is the source of | |||
| 235 | + | // truth for the font rule mirrored above. | |||
| 227 | func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget { | 236 | func (c *Core) hintSpread(sc *screen, ts []hintTarget) []hintTarget { | ||
| 228 | lw, lh := c.hintLabelSize(sc) | 237 | lw, lh := c.hintLabelSize(sc) | ||
| 229 | order := slices.Clone(ts) | 238 | order := slices.Clone(ts) | ||
| 230 | slices.SortStableFunc(order, func(a, b hintTarget) int { | 239 | slices.SortStableFunc(order, func(a, b hintTarget) int { | ||
| 231 | return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | 240 | return cmp.Or(cmp.Compare(b.score, a.score), cmp.Compare(a.rect.Y, b.rect.Y), cmp.Compare(a.rect.X, b.rect.X)) | ||
| 232 | }) | 241 | }) | ||
| 233 | - | kept := order[:0:0] | 242 | + | if len(order) > 4*hintMaxTargets { |
| 243 | + | order = order[:4*hintMaxTargets] | |||
| 244 | + | } | |||
| 245 | + | kept := order[:0] | |||
| 234 | for _, t := range order { | 246 | for _, t := range order { | ||
| 235 | clash := false | 247 | clash := false | ||
| 236 | for _, k := range kept { | 248 | for _, k := range kept { | ||
| 237 | - | if abs(t.rect.X-k.rect.X) < lw && abs(t.rect.Y-k.rect.Y) < lh { | 249 | + | a, b := hintLabelRect(sc, t.rect, lw, lh), hintLabelRect(sc, k.rect, lw, lh) |
| 250 | + | if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { | |||
| 238 | clash = true | 251 | clash = true | ||
| 239 | break | 252 | break | ||
| 240 | } | 253 | } | ||
A internal/core/hint_internal_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "math" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHintLabelGeometry(t *testing.T) { | |
| 11 | + | for _, scale := range []float64{1, 1.5} { | |
| 12 | + | sc := &screen{scale: scale, info: ports.OutputInfo{Width: 1500, Height: 1200}} | |
| 13 | + | sc.mon = newMonitor("", "") | |
| 14 | + | sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale)) | |
| 15 | + | c := &Core{} | |
| 16 | + | c.cfg.Hint.Size = 200 | |
| 17 | + | w, h := c.hintLabelSize(sc) | |
| 18 | + | font := math.Min(200, 256/scale) | |
| 19 | + | require.Equal(t, int(math.Ceil(1.2*font+6*font/13+2/scale+1)), w) | |
| 20 | + | require.Equal(t, int(math.Ceil(1.25*font+2*font/13+2/scale+1)), h) | |
| 21 | + | bounds := sc.rect() | |
| 22 | + | // Corner separation exceeds label width, but right-edge clamping folds | |
| 23 | + | // the second label back over the first. | |
| 24 | + | a := hintTarget{rect: ports.Rect{X: bounds.W - w - 1, Y: bounds.H - h - 1}, score: 1} | |
| 25 | + | b := hintTarget{rect: ports.Rect{X: bounds.W - 1, Y: bounds.H - 1}, score: .5} | |
| 26 | + | require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 1) | |
| 27 | + | b.rect = ports.Rect{X: 0, Y: 0} | |
| 28 | + | require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 2) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestHintSpreadBudget(t *testing.T) { | |
| 33 | + | sc := &screen{scale: 1, info: ports.OutputInfo{Width: 1000000, Height: 1000}} | |
| 34 | + | sc.mon = newMonitor("", "") | |
| 35 | + | sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale)) | |
| 36 | + | c := &Core{} | |
| 37 | + | ts := make([]hintTarget, 4*hintMaxTargets+10) | |
| 38 | + | for i := range ts { | |
| 39 | + | ts[i] = hintTarget{rect: ports.Rect{X: 100 + i*100, Y: 100}, score: float32(i)} | |
| 40 | + | } | |
| 41 | + | got := c.hintSpread(sc, ts) | |
| 42 | + | require.Len(t, got, 4*hintMaxTargets) | |
| 43 | + | require.Equal(t, float32(len(ts)-1), got[0].score) | |
| 44 | + | require.Equal(t, float32(10), got[len(got)-1].score) | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "math" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHintLabelGeometry(t *testing.T) { | |||
| 11 | + | for _, scale := range []float64{1, 1.5} { | |||
| 12 | + | sc := &screen{scale: scale, info: ports.OutputInfo{Width: 1500, Height: 1200}} | |||
| 13 | + | sc.mon = newMonitor("", "") | |||
| 14 | + | sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale)) | |||
| 15 | + | c := &Core{} | |||
| 16 | + | c.cfg.Hint.Size = 200 | |||
| 17 | + | w, h := c.hintLabelSize(sc) | |||
| 18 | + | font := math.Min(200, 256/scale) | |||
| 19 | + | require.Equal(t, int(math.Ceil(1.2*font+6*font/13+2/scale+1)), w) | |||
| 20 | + | require.Equal(t, int(math.Ceil(1.25*font+2*font/13+2/scale+1)), h) | |||
| 21 | + | bounds := sc.rect() | |||
| 22 | + | // Corner separation exceeds label width, but right-edge clamping folds | |||
| 23 | + | // the second label back over the first. | |||
| 24 | + | a := hintTarget{rect: ports.Rect{X: bounds.W - w - 1, Y: bounds.H - h - 1}, score: 1} | |||
| 25 | + | b := hintTarget{rect: ports.Rect{X: bounds.W - 1, Y: bounds.H - 1}, score: .5} | |||
| 26 | + | require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 1) | |||
| 27 | + | b.rect = ports.Rect{X: 0, Y: 0} | |||
| 28 | + | require.Len(t, c.hintSpread(sc, []hintTarget{a, b}), 2) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestHintSpreadBudget(t *testing.T) { | |||
| 33 | + | sc := &screen{scale: 1, info: ports.OutputInfo{Width: 1000000, Height: 1000}} | |||
| 34 | + | sc.mon = newMonitor("", "") | |||
| 35 | + | sc.mon.SetOutput(int(float64(sc.info.Width)/sc.scale), int(float64(sc.info.Height)/sc.scale)) | |||
| 36 | + | c := &Core{} | |||
| 37 | + | ts := make([]hintTarget, 4*hintMaxTargets+10) | |||
| 38 | + | for i := range ts { | |||
| 39 | + | ts[i] = hintTarget{rect: ports.Rect{X: 100 + i*100, Y: 100}, score: float32(i)} | |||
| 40 | + | } | |||
| 41 | + | got := c.hintSpread(sc, ts) | |||
| 42 | + | require.Len(t, got, 4*hintMaxTargets) | |||
| 43 | + | require.Equal(t, float32(len(ts)-1), got[0].score) | |||
| 44 | + | require.Equal(t, float32(10), got[len(got)-1].score) | |||
| 45 | + | } | |||