Animation of a conveyor belt carrying objects that arrive touching, in runs of three. A counting line sits across the belt. The occupancy signal measured on that line goes high when the first object of a run arrives and stays high until the last one leaves, so the whole run produces a single merged pulse with no seams in it — the threshold counter sees one falling edge and adds one, under-counting the run by two. A detector draws a separate box around each object, so the seams the signal cannot see are visible to it, and its counter adds three for the same run. The two counters drift steadily apart.

When counting needs a detector on conveyor belt


The previous article counted objects crossing a line with a threshold on a strip of pixels, no model at all, on a $237 Raspberry Pi. That solution rests on one assumption stated up front: the objects arrive cleanly separated. This article removes that assumption and nothing else. Same belt, same throughput under 10 per second, same single lane. The objects now touch.

That one change breaks the counter, and it breaks it in a way that is worth understanding before reaching for a model - because the first two fixes are still not machine learning, and one of them is better than YOLO for most lines.

Why the threshold solution breaks

The old counter watches one column of pixels and asks a single yes/no question: is something there on the line? It counts falling edges. Three separated objects give three pulses and the count becomes three.

Now push those three objects together. Occupancy goes high when the first arrives and stays high until the last leaves. One pulse -> Count of one. The signal the counter reads simply does not contain the boundaries any more - not because the threshold is badly tuned, but because a single column of pixels cannot see a seam that runs along the belt.

The previous article’s run-length patch is the honest first response:

n = max(1, round(frames_on / FRAMES_PER_ITEM))
count += n

Divide the run length by the length of one object. It works, and on a surprising number of lines it is where the story should end. But look at where it fails, because the failure mode tells you exactly what you need next.

Its error is proportional to the run length. If your estimate of FRAMES_PER_ITEM is 5% off, a run of two objects is off by 0.1 - rounding absorbs it. A run of twenty is off by one whole object, and rounding no longer saves you. The same 5% that was invisible at doubles becomes a miscount at long runs. And FRAMES_PER_ITEM is off by more than 5% the moment your product has natural size variation, or the belt speed drifts with load, or a VFD ramps on start-up.

It is not that “run-length is a hack.” It is:

Run-length division is correct when runs are short and objects are uniform. It degrades continuously with run length and with size variance.

That is the real dividing line, and it is worth stating in numbers. Two different errors are at work in a run of kk touching objects, and they grow at different rates - which is the whole point.

Size variance grows as σk\sigma\sqrt{k}. Let each object have length LiL_i, drawn independently around a mean LL with standard deviation σL\sigma L. The run’s measured length is the sum i=1kLi\sum_{i=1}^{k} L_i. For independent draws, variances add - standard deviations do not:

Var(i=1kLi)=kσ2L2SD=σLk\operatorname{Var}\left(\sum_{i=1}^{k} L_i\right) = k\,\sigma^2 L^2 \quad\Longrightarrow\quad \text{SD} = \sigma L \sqrt{k}

Divide by LL to put it in object-lengths and the error is σk\sigma\sqrt{k}. The intuition behind the square root is worth holding onto: some objects run long and some run short, so they partially cancel each other inside the run. Four objects do not have four times the error of one, they have twice.

Calibration error grows as ϵk\epsilon k. If FRAMES_PER_ITEM is off by a fraction ϵ\epsilon, every object in the run is wrong by ϵL\epsilon L in the same direction. There is nothing to cancel, so the errors add directly rather than in quadrature, and the total is ϵk\epsilon k - linear in kk.

That difference is the reason the second term dominates. A random error you can out-run by averaging; a systematic one you cannot. Set the two against each other and rounding survives while

ϵk+σk<0.5\epsilon k + \sigma\sqrt{k} < 0.5

At ϵ=0.03\epsilon = 0.03 and σ=0.05\sigma = 0.05, that holds to about k=8k = 8. At ϵ=0.10\epsilon = 0.10 it fails at k=4k = 4 - and note that σ\sigma barely moved the answer either time. Calibration quality, not object count and not product consistency, is what sets your ceiling. Which is uncomfortable, because σ\sigma is a property of the product you can measure once, while ϵ\epsilon drifts with belt speed and load after you have left the site.

The fix that is not a model

Before the detector, one more non-ML option, and on many lines it is the correct engineering answer.

Separate the objects mechanically. If you can pull them apart upstream of the camera, the entire previous article applies unchanged and you are back to a $237 build with no model to maintain. The previous article mentioned this in passing; here it deserves the numbers, because a speed-up belt is often cheaper than the compute you would otherwise buy.

Recall from the last article that if a belt runs at speed vv and delivers RR objects per second, the spacing between object starts is

p=vRp = \frac{v}{R}

The gap between objects is pLp - L, so objects touch exactly when pLp \le L. Now put a second, faster belt after the first. Throughput RR is conserved across the transfer - the same objects per second have to come out as go in - so raising vv raises pp in direct proportion. Doubling belt speed doubles the spacing.

For touching objects on the infeed (p1=Lp_1 = L), the speed ratio you need to open a gap gg is

v2v1=L+gL\frac{v_2}{v_1} = \frac{L + g}{L}

A 100 mm object needing a 50 mm gap wants a 1.5× speed-up. That is a small drive change, not a new architecture - and it is the same trick a singulator uses.

Two things make this better than it looks. First, from the previous article, frames per object does not depend on belt speed - N=(fps/R)×dN = (\text{fps}/R) \times d - so running the second belt faster costs you nothing in frames. The only bill is exposure time, and it scales linearly: 1.5× the speed needs 1.5× shorter exposure and correspondingly more light. Second, the duty cycle d=L/pd = L/p falls, which is what a debounce wants anyway.

The cases where this does not work are specific and you will recognise yours: objects that are fragile, that tumble when accelerated, that are already committed to a fixed-pitch indexing conveyor, or a line with no physical room for another metre of belt. If none of those apply, buy the belt, not the GPU. A speed-up section is a few hundred dollars once, against a detector that is a training set, a retraining cycle and a piece of hardware forever.

What a detector actually buys

Now assume separation is genuinely impossible - the objects are dropped touching onto a wide belt and have to be counted as they lie. This is where YOLO earns its cost, and it is worth being precise about what it earns, because it is not “accuracy.”

A threshold answers is something there. A detector answers how many things are there, and where is each one. The output is a list of boxes per frame, each with a class and a confidence. The count of boxes is the count of objects in that frame. The seam between two touching objects, invisible to a single column of pixels, is visible to a model that has been shown a few thousand examples of what one object looks like.

That is the only new thing we get . Everything else is the price we pay to get it.

The cost you are actually paying

Three costs, and only one of them is the GPU.

A labelled dataset. A few thousand boxes drawn by a person, covering every product variant, every lighting condition you failed to control, and - critically - plenty of touching examples, because a model trained only on separated objects learns the wrong prior. This is the expensive part, and it does not go away: it recurs every time the product changes.

Inference hardware. The previous article’s Pi 5 does one subtract and one compare over 28,800 values per frame. A detector is a few billion multiply-accumulates per frame. That is not a tuning difference, it is a different machine.

A retraining loop, forever. New product, new supplier, new belt colour, and the model needs attention. In previous solution, the threshold needed a number update but here the model needs a dataset extended and a retrain validated. Budget for the person, not just the hardware.

Frame rate is now the enemy, not the friend

Here is the opposite part from the previous article’s threshold solution.

The threshold solution wanted high frame rate - “fps ≥ 10 × objects per second” - because frames were nearly free and a debounce needs several of them. A detector’s frames are not free. At 100 fps you have a 10 ms budget per frame, and that budget must cover capture, preprocessing, inference and tracking. On a Jetson Orin Nano, a small YOLO at 640×640 runs in roughly 10–20 ms depending on model size and precision. You get 50–100 fps if you spend the entire machine on inference and nothing else, hard to achieve.

So the design pressure reverses. With a threshold solution, you get more frames because they are cheap, increase accuracy by avoiding from a stray fibre or a fly. With a detector every frame costs real milliseconds, and the question becomes the minimum frame rate that still lets you associate a box in this frame with the same object in the next one. That minimum is set by tracking, not by detection - which is the next section, and the reason detection alone is not a counter.

Practical consequences worth planning for:

  • Crop before you infer. You do not need 1456×1088. Feed the detector only the belt region. Fewer pixels is directly less compute, and it also removes background the model can hallucinate on.
  • Use the smallest model that works. Start at nano-scale and only go up if validation says so. The instinct to start large and prune later wastes weeks.
  • Quantise. INT8 via TensorRT is typically a 2–3× speed-up over FP16 for a small accuracy cost. On an edge box this is usually the difference between fitting the budget and not.
  • Lock the input resolution early. It is entangled with dataset labelling, anchor behaviour and latency all at once, and changing it late invalidates your benchmarks.

Detection is not same as counting

A detector tells you there are three objects in this frame. It says nothing about whether they are the same three objects that were in the previous frame. Run a detector at 30 fps over a belt and naively sum the boxes, and an object sitting in view for 15 frames is counted 15 times.

The threshold solution never had this problem, and it is worth seeing exactly why: it watched a line, not an area. An object crosses a line once. The falling edge is the count, and the state machine’s frames_on was doing identity tracking for free - implicitly, for a single lane, because only one object can be on the line at a time.

Widen the view to an area with several objects in it, and that free identity is gone. You now need multi-object tracking: assign each detection a persistent ID, and count each ID once.

The standard shape of the solution is a line-crossing counter over tracked IDs:

  1. Detect boxes in the current frame.
  2. Associate them with existing tracks, usually by IoU overlap or centroid distance, often with a Kalman filter predicting where each track should be. This is the core of SORT and its descendants.
  3. Keep each track’s previous centroid.
  4. When a track’s centroid crosses the counting line - previous frame on one side, current frame on the other - increment, and mark that track as counted so it can never count twice.

Code

The same shape as the previous article: constants at the top, then a loop over frames. The constants divide the same way too - the first is pure geometry, the rest are thresholds.

import cv2
from ultralytics import YOLO

LINE_X = 700           # column of the counting line, in pixels
CONF = 0.50            # minimum detection confidence
IOU = 0.50             # NMS overlap threshold
CLASSES = [0]          # class ids to count; None counts everything
IMGSZ = 640            # inference resolution - lock this early
MAX_MISSES = 5         # drop a track unseen for this many frames

model = YOLO("best.pt")            # your trained weights, not the COCO ones
cap = cv2.VideoCapture("belt.mp4")

tracks = {}            # id -> {"cx": float, "counted": bool, "misses": int}
count = 0

while True:
    ok, frame = cap.read()
    if not ok:
        break

    # persist=True keeps tracker state across calls; without it every frame
    # starts fresh and every object gets a new id, so nothing ever crosses.
    result = model.track(
        frame,
        persist=True,
        conf=CONF,
        iou=IOU,
        classes=CLASSES,
        imgsz=IMGSZ,
        tracker="bytetrack.yaml",
        verbose=False,
    )[0]

    boxes = result.boxes
    seen = set()

    if boxes is not None and boxes.id is not None:
        xyxy = boxes.xyxy.cpu().numpy()
        ids = boxes.id.cpu().numpy().astype(int)

        for tid, (x1, _, x2, _) in zip(ids, xyxy):
            cx = (x1 + x2) / 2
            seen.add(tid)
            track = tracks.setdefault(
                tid, {"cx": cx, "counted": False, "misses": 0}
            )
            track["misses"] = 0

            # count on the crossing, not on the position: the object must have
            # been on one side last frame and the other side this frame
            if not track["counted"] and track["cx"] < LINE_X <= cx:
                count += 1
                track["counted"] = True

            track["cx"] = cx

    # age out tracks that have left the frame, so the dict cannot grow forever
    for tid in list(tracks):
        if tid not in seen:
            tracks[tid]["misses"] += 1
            if tracks[tid]["misses"] > MAX_MISSES:
                del tracks[tid]

print(count)

Five things worth pointing at in the code.

persist=True is the whole tracker. Leave it out and Ultralytics restarts tracking on every call, so every object receives a fresh ID every frame, track["cx"] is always the value just written, and the crossing test never fires. The counter reports zero forever and the detections look perfect in the debug view. This is the single most common way this code can be broken.

The crossing test is a comparison of two frames, not a region test. track["cx"] < LINE_X <= cx asks whether the object was on the left last frame and is on the right now. The tempting alternative - if cx > LINE_X - counts every object on the right-hand half of the frame on every frame it is visible. The asymmetric < and <= matter: they make the boundary belong to exactly one side, so an object landing precisely on LINE_X cannot be counted twice.

counted is the direct descendant of MIN_FRAMES. In the threshold solution the debounce stopped one object producing several counts. Here the flag does it, and it also solves the nudged-back-and-forth case the previous article needed two lines for - a track that wobbles across the line is still one track, and it is already marked. Note that the flag is per-track, so it costs nothing as objects multiply.

The misses loop is not tidiness, it is a leak fix. Without it tracks accumulates an entry for every object that has ever crossed the frame. On a line running three shifts that is a slow memory leak that takes a fortnight to bring the box down. MAX_MISSES also gives a track a few frames of grace to survive a missed detection without being reborn under a new ID.

best.pt, not yolo11n.pt. The pretrained COCO weights know about people and cars, not your product. The line loading the weights is the visible tip of the labelling cost from earlier - the code is short precisely because the dataset is where the work went.

Two practical notes for getting this to production. Develop against recorded video exactly as before, so you can replay the same clip while tuning CONF. And when you deploy, export to TensorRT rather than running the .pt file - model.export(format="engine", half=True) gives you an engine file that YOLO() loads the same way, typically 2–3× faster on the Jetson. Everything else in the loop is unchanged.

This is where the compute actually goes on a real line. People budget for the detector and are surprised by the tracker. Association is cheap per pair but grows with the square of the objects in view, and the Kalman predict-update runs per track per frame. On a wide belt with thirty objects in view, tracking is a real fraction of the frame budget.

The failure mode you have to design against

Tracking introduces a failure the threshold solution could not have: the ID switch. Two objects pass close, the association step swaps their IDs, and the counter either double-counts one or misses the other. Occlusion causes it, low frame rate causes it, and objects that look identical - which yours do, they came off the same mould - cause it worst of all, because appearance features cannot break the tie.

Three defences, in the order I would apply them:

  • Raise frame rate until the per-frame motion is small relative to object spacing. If an object moves less than half its own width between frames, IoU association is nearly unambiguous. This is the single most effective fix, and it is the reason frame rate still matters even though frames are now expensive.
  • Put the counting line where objects are most separated, even if that means a longer belt run before the camera. Association errors cluster where objects are close together.
  • Constrain the motion model. On a conveyor everything moves in one direction at one speed. A tracker that knows this rejects impossible associations for free - and generic trackers do not know it unless you tell them.

That first defence deserves a number, because it is the one that sets your hardware. If objects must move less than half a width between frames, then v/fps<L/2v/\text{fps} < L/2, giving

fps>2vL\text{fps} > \frac{2v}{L}

A 100 mm object on a 500 mm/s belt needs more than 10 fps for reliable association - comfortably inside what a detector can do. But a 30 mm object on that same belt needs more than 33 fps, and now you are at the edge of the Jetson’s inference budget. Small objects on fast belts are what make this problem expensive, and it is worth checking that inequality before quoting the job.

Cost

The previous article’s build was $237. Here is the same table for the detector build, with the changed rows called out.

ItemThreshold buildDetector build
ComputePi 5 4 GB, $60Jetson Orin Nano 8 GB, $250
CameraGlobal Shutter IMX296, $50same, $50
LensC/CS mount, $25same, $25
Power27 W USB-C, $12barrel supply, $20
Storage32 GB microSD, $10NVMe SSD, $40 - see below
LightingDC LED bar, $40same, $40
Mountingbracket, shroud, $30same, $30
PLC outputopto-isolated, $10same, $10
Hardware total$237$465
Labellingnone2–5 days of a person
Retrainingnonerecurring, per product change

The hardware roughly doubles, which is the part everyone quotes. It is not the important number. The important rows are the last two, and they have no dollar figure because they are ongoing. A threshold solution that is deployed is finished. A model that is deployed has an owner.

Note also which rows did not move: camera, lens, lighting and mounting are identical. Everything the previous article said about lighting still applies in full - a detector is more tolerant of lighting variation than a threshold, not immune to it. The temptation to skip the lighting work because “the model will handle it” is the single most expensive mistake available here. It converts a solved physical problem into an unsolved statistical one.

The SSD is the row worth explaining, and it is the one conditional line in the table - the equivalent of the previous article’s opto-isolated PLC output. It is not needed to run a detector. It is needed to record video on the line, either for capturing training dataset, debugging or dispute resolution.

A microSD is fine for a counter that writes a number to MQTT, and it is fine for a Jetson that only ever loads weights and runs inference. What kills SD cards is sustained writes, and the only thing writing sustainedly here is clip capture for the growing dataset. So:

  • Recording on the line? Buy the SSD. Continuous 1080p clip capture is tens of GB a day, and an SD card doing that will fail in months - silently, corrupting the recordings you were collecting.
  • Not recording? Drop the row and the build is $425. The weights load once at start-up and the counter writes a number to the network, which is exactly the microSD’s job.
  • Recording only during commissioning? A common middle path, and my usual recommendation: run the SSD while you are collecting the dataset and tuning, then either leave it in place for future retrains or move it to the next line you are commissioning. One SSD can serve several deployments in sequence.

The one case that pushes the SSD back to mandatory even without a dataset is the diagnostic ring buffer from the previous article - keeping the last minute of frames so that a disputed count can be dumped and reviewed. If the count feeds piece-rate pay for factory workers, someone will eventually dispute it and $40 is a cheap way to be able to settle the dispute.

Which solution, for which line

The decision does not need a flowchart. Three questions in order, and the first “yes” is your answer:

Can you separate the objects mechanically? Then do that and use the previous article’s threshold. A speed-up belt at L+gL\frac{L+g}{L} times the infeed speed, a $237 Pi, no model. This is the answer more often than people expect, and it is the answer they most often skip past.

Are runs short - two or three objects - and is the product dimensionally consistent? Then run-length division on the threshold. Check it against the ϵk+σk<0.5\epsilon k + \sigma\sqrt{k} < 0.5 inequality with your own numbers, and calibrate FRAMES_PER_ITEM from recorded video rather than the spec sheet. Re-check it whenever the product changes.

Neither? Then a detector, and budget for tracking, labelling and retraining - not just the Jetson.

The failure I have seen most often is jumping straight to the third answer because it is the interesting one. The line that gets a $465 box and a labelling project, when a 1.5× speed-up belt would have made the whole problem disappear, is a real line I have stood in front of.

How do you know the count is right?

Unchanged from the previous article, and now more necessary rather than less. Record an hour of production video, hand-count it once, and keep it forever as the regression test. Reconcile in production against something the counter cannot see - a check-weigher, the packing count, the next station’s tally.

One addition specific to this build. Log the detection count and the crossing count separately. They answer different questions: detections falling off means the model has drifted - new product, dirty lens, changed lighting - while detections holding steady but crossings falling means tracking is failing and you have an ID switch problem. Same symptom at the dashboard, completely different fix, and without the two numbers side by side you will spend a day finding out which one you have.

Next article: what happens when the objects are stacked, why one camera cannot count them by construction, and what a second viewpoint actually costs.