Detection worker
Source: src/app/infrastructure/detection/worker
Running a neural network on every frame of a video takes minutes. If that ran
on the GUI thread the window would freeze. DetectionWorker does the slow
loop on a background thread: it reads frames in order, asks the selected
detection engine about each one, keeps the answers, and sends
batches and progress to the rest of the app through Qt signals.
Its job is deliberately narrow. It never chooses annotation layers, edits boxes, or touches widgets. The Application detection service decides what happens to the results; they become Layer A (original detector output) and the machine part of Layer B (reviewed/editable detections). See Video layers.
Who creates and owns the worker
Solid arrows are calls or creation; the dashed arrow means "keeps a reference".
Boxes grouped under GUI thread run on the Qt event loop; the worker's
run() is the only code in this picture on the background thread.
DetectionWorkeris itself aQThread(not a plain object moved to a thread). Callingstart()runsrun()on the new thread. The class docstring'smoveToThreadexample does not match the code or how the app uses it.DetectionServicecreates it through a factory, connectsbatch_readyandprogress_updated, starts it, and stores it on the session. The handler then connectsprogress_updated,finished_processing, anderror_occurredfor UI feedback.- Before creating it the service rejects a second run
(
WorkerAlreadyRunningException) or a missing model (NullModelNameException), then clears all layers and (re)creates the engine for the session. - The
VideoReaderis opened insiderun()and closed in itsfinally, so decoding also stays on the worker thread. The engine is shared by reference, not copied.
The per-frame loop
Solid arrows are the normal path for one frame; dashed arrows leave the loop. Diamonds are decisions.
- Check
_stop_requested; leave the loop if set. - Read the next frame. The reader's own frame index is used as the key.
EndOfVideoStreamExceptionmarks the stream as complete, callsstop(), and leaves the loop. - Detect with
engine.detect(frame): one model call per frame. - Cache the result in
_detections_by_frame_indexwhile holding a mutex, and add it to the current batch. - Batch: at 30 frames emit
batch_readyand start a new batch. - Progress: emit
progress_updated(done, total, ETA)after every frame.
The cache is written by the loop, read by others
The loop never looks results up, so there is no cache hit/miss decision
inside it. The cache exists for outside callers: get_detections(i)
returns a copy of one frame's list, or None when the frame is not yet
processed; get_all_detections() returns a copy of everything. Both take
the same mutex, so reads are safe while processing continues. The
Application's batch path does not use them; it consumes batch_ready.
The cache grows for the worker's lifetime (no eviction).
A 65-frame video
| Batch | Zero-based frame indices | Frames in batch |
|---|---|---|
| First full batch | 0-29 | 30 |
| Second full batch | 30-59 | 30 |
| Final flush at stream end | 60-64 | 5 |
A batch is not one 30-image model call: detect(frame) runs once per
frame and results are only grouped for delivery.
Signals, progress, and stopping
Arrows show who calls or emits to whom; time runs downward.
| Signal | Payload | Meaning |
|---|---|---|
batch_ready |
session ID, {frame index: detections}, elapsed seconds |
A batch for the service to store |
progress_updated |
frames done, total frames, ETA in ms | Position after each frame; independent of batches |
error_occurred |
message | An exception reached the worker's handler |
finished_processing |
none | run() is leaving; says nothing about success |
- The ETA is a running average since the start (elapsed / frames done ×
frames left). When the frame count is unknown (
<= 0) the total reported is the current frame and the ETA isNone, although the signal is declared with afloat. batch_ready's time value is measured from the start of the run, not per batch, so it grows; the service only logs it.stop()only sets a flag. It is checked between frames, so the model call in flight completes first.start()on a running worker is a no-op, and a freshstart()resets the stop/complete flags.is_complete()is true only if the end of the stream was reached.- Stopping happens via the session (closing it calls
stop(); so does switching the detection model, which then drops its worker reference without waiting). Neither path joins the thread. Frames already delivered are not rolled back.
Thread delivery of the service slots
DetectionService is a plain Python object with @Slot-decorated
methods, not a QObject. Its docstring says the batch slot runs on the
main thread, but the code does not guarantee a queued hop to the GUI
thread for these connections. Check this before adding anything in those
slots that is not thread-safe. UI handler slots on QObjects do not have
this issue.
From batch to Layer A
Solid arrows are the data path; the dashed arrow is the skip.
For each frame the processor first checks whether Layer A already has boxes
there; if so the frame is skipped (A is never overwritten). Otherwise it maps
the results to boxes, keeps those that pass the session's chosen labels (empty
selection allows all) and minimum detection confidence, appends them to A,
and replaces the machine boxes in B, leaving manual boxes alone.
Because the service clears all layers before starting a run, skipping mainly
matters for repeated or overlapping batches. A batch for a session that was
just closed is silently dropped (KeyError).
Failure and cleanup paths
Solid arrows are the exits; the dashed box is the normal stop path.
- Stop or end of stream → the partial batch is flushed, then cleanup.
- Exception (reading,
detect, anything in the loop) → logged with its traceback,error_occurred(str(e))emitted, and the partial batch is not flushed. Earlier batches stay stored. - Always →
reader.close()thenfinished_processing. The handler uses it to clear the busy indicator and shows an error dialog only fromerror_occurred; the worker itself never opens dialogs. - A provider may swallow its own inference errors (YOLO returns an empty list), so an empty result does not always mean "no objects"; see Detection models.
Limitations
No pause/resume, no retry of a failed frame, and no per-frame error isolation: one raised exception ends the run. Cancellation latency is one frame of decode plus inference.