FIELD NOTE / COMPUTER VISION

Croatia · Traffic cameras · Ferry queues · croatianferries.com

I stopped counting cars. I ask whether that patch is still road.

2026-09-10  ·  ~12 min read  ·  by Gal Tidhar

Episode 1 of this series was distribution: 52 Facebook groups and the comments as my issue tracker. Episode 2 was week one in search. This one is the piece episode 1 promised: a live ferry queue read from public traffic cameras. It works, it is on the site, and the part of it that is genuinely hard to copy is not the model. It is the picture of that road empty at seven in the morning, which you can only get by having watched the road for nine days first.

The sentence that changed the design

Dennis lives on Krk and uses Valbiska–Merag regularly. He sends me feedback on the site, and in a voice note he said this:

“On Valbiska I need to just check how many, I would say, rows - I know when I see it, I know if I can get on or not. It's hard to explain in English. And also on Merag, I know exactly where the last car is, if I have a place or not.”

He does not count cars. Nobody counts cars. He looks at where the queue ends and compares it to a landmark he has in his head. That is a completely different computer-vision problem from the one I had been solving, and a much easier one.

What I had been doing wrong, for weeks

The project ran for a long time on an object detector, counting vehicles in a region of interest. Every time the number came out too small I produced a physical explanation: the camera is 300 metres from the bend, a car there is eight pixels tall, there is a resolution wall, tiled inference will not save it, upscaling will not save it. All of that sounded right and all of it was wrong.

The detector was YOLO-World, open-vocabulary, prompted with “car”, “van”, “camper van”. On merag/488/2026-09-06/073003.jpg - a frame with a visible line of about fifteen cars up the road - it returned 2 boxes for the entire frame. Plain pretrained yolov8s.pt on COCO classes, confidence 0.10, imgsz=1280, found 15 inside the counting region alone. Across one Sunday at Merag: mean 2.2 against 8.0, max 6 against 15.

The tell had been sitting in my own code comment for a fortnight: a threshold change “cut the 08:08 peak from 5 vehicles to 2”. A dozen cars being seen as five, read as a threshold nuance. When a detector disagrees with a human looking at the same frame, swap the model before theorising about optics. Everything calibrated through the old detector had to be thrown away and re-measured.

Not road

Even with a working detector, counting is the wrong quantity. A ferry on this coast takes 100+ vehicles and the camera resolves a fraction of them; “at least 12 waiting” is both too small to inform a decision and falsely precise about what a traffic camera can see. So the rule became: I do not care about the word car. I care whether this patch of road is still road.

Which is background subtraction, with one detail that makes it work outdoors:

  1. A reference per zone, per hour of day. The per-pixel median of the same zone at the same hour across nine days. A vehicle stands somewhere different every day, so it does not survive a median of ~36 frames. Tarmac, lane paint, the guardrail and the hour's own shadows do.
  2. Level the brightness before comparing. Subtract each patch's own median inside the polygon, on both the frame and the reference. A passing cloud moves the whole square together and must not read as a car; a vehicle moves only part of it.
  3. Count what moved. The fraction of polygon pixels whose grey value differs by more than 22 levels.

That is the whole method - about 70 lines in src/ferry_watch/notroad.py, OpenCV and NumPy, no training, no labels, no GPU.

The threshold picks itself

Sampled every five minutes across three days of daylight (576 readings) the yellow zone's distribution comes out with two clean humps: a mass at 0–10% changed, which is empty tarmac, and a mass at 30–60%, which is occupied. Between them, a valley at 20–25% with almost nothing in it. The threshold sits in the valley - 0.20 - and it was not chosen by hand. If a zone does not produce two humps, that zone does not get to ship.

Green, yellow, red - and where the method dies

Gal's three squares (marked 8 Sept) are read in order, because the road fills from the port outwards:

  • green - the first stretch of approach road the queue reaches, camera 487, appearance.
  • yellow - the stretch further out, camera 487, appearance.
  • red - the far bend, camera 488. A vehicle standing there means this sailing is full. That is a domain definition, not a tuned threshold.

The appearance method fails completely on the bend. It reads a flat 17–22% changed whether the bend is empty, holding a car or holding a queue: at that distance a vehicle is a handful of pixels while the permanent mismatch from foliage and camera jitter is already a fifth of the patch. Eroding the polygon to exclude the greenery made it worse. So the bend keeps the detector, which works there fine (7 boxes on the busiest frame, 0 on an empty one). Two different methods, chosen per zone by which one separates.

One more guard: a colour is only taken when it holds across two consecutive minutes. A car driving down to the restaurant below the port appears in one frame and not the next, and a traffic light that flickers is worse than no traffic light.

Time to full, measured rather than predicted

The question a driver actually has is not “what colour is it”, it is “do I have time”. That number comes out of the archive, not out of a model: replaying nine days and timing each cycle from the moment a zone lights to the moment the bend is occupied gives green → full, median 18 minutes (16 daytime cycles, range 4–34) and yellow → full, median 14 (range 2–24). Dawn sailings are a separate population, 26–60 minutes, and are excluded rather than averaged in.

The page prints the median with its range, and calls it what it is: what this port did on the days we watched it.

The ship: edges, not colour

The other half of the picture is whether a boat is actually on the ramp, from camera 37. Colour fails - a hazy morning sea saturates it. Brightness fails - at night the lit ship is brighter than anything else in frame. What works is edge density in a fixed rectangle: a ship is railings, windows and a hull line, and the sea stays smooth however the light falls on it.

Measured a frame a minute across 8 September: empty 0.05–0.14, alongside 0.21–0.28 (0.26–0.28 lit at night). Threshold 0.16, in the valley again, with 1.8% of frames landing inside it - which are the arriving and departing minutes themselves.

Two things fell out of replaying that against the timetable, both non-obvious:

  • The boat is alongside from about 25 minutes before a departure to about 5 minutes after it, not the flat “30 minutes before” a timetable window assumes.
  • On 8 September it was alongside three more times than the printed timetable lists. 16 departures seen against 13 printed. Either a second vessel or sailings Jadrolinija adds and publishes nowhere. The detector was right each time; the timetable was short.

So the page now logs every arrival and every departure the ramp camera saw, and draws the day on a single rule: printed sailings as ticks above it, sailings actually observed as ticks below it. Twelve ticks under eight is the entire argument in one glance. No tick is paired with another - whether the 13:35 was the 13:00 running late or a boat nobody printed is not knowable from a ramp camera, and guessing would be the one dishonest thing in the picture.

One boat clears a full queue

The page used to strike out the next sailing whenever the port read full, and promote the one after it. On 10 September, measured end to end, that would have been wrong for every car in the queue:

  • 17:17 - the light goes red, the queue has reached the bend, a lorry standing in the polygon.
  • 17:33 - one ferry leaves, with the whole line aboard.
  • 18:07 - the road is green again.

Fifty minutes from “you have no chance” back to an empty road, on one sailing. Whether a full road costs you a departure is a capacity question, and no camera here measures capacity. So the page says the two things it actually measured - the port has been full since X, and the ship is on the ramp - and leaves the judgement to the person looking at the photograph beside those words. The promotion comes back only when the colour log shows red surviving a sailing; that log started the same evening.

Two mistakes worth more than the method

  • A validation set sorted by the thing you are detecting cannot fail. An earlier lane-occupancy number was checked on five frames, four of which were the week's busiest, and the readings came out neatly ordered 0, 1, 3, 4. That felt like confirmation. It was an artefact: any measure monotone in traffic would have passed. Replayed every 20 minutes across a whole Sunday, the same number read zero on 24 of 43 daytime frames and swung 0-4-0 between neighbours. It is not on the site.
  • Do not report a conclusion while the replays are still running. Twice in one week I sent a confident read drawn from whichever slice had finished. The signals at Valbiska were declared fine (quiet week), then misaimed and due for retirement (one Sunday), and Saturday refuted both: the vehicle counts are reachable, the sustained durations are not. The failing condition was never the count.

The moat, honestly

None of the code here is clever. A detector is pip install ultralytics. Background subtraction is in every textbook. What is not downloadable is what this specific road looks like empty at 06:00, at 13:00 and at 21:00, in this season, with this camera's jitter and this hillside's shadows. That reference exists because a small box in Falkenstein has been pulling a frame every twenty seconds since 29 August.

Today that is 311,537 frames across five cameras - 13 days at the three Merag cameras, 10 at the two Valbiska ones. It is not a smart asset. It is a boring one that only starts accruing on the day you begin keeping it, which is exactly what makes it awkward to copy.

What is still not working

  • Valbiska has no equivalent yet. It is a marshalling lot, not a linear road, so “where does the queue end” becomes “how many lanes are full”. Ten lanes, filling in a contiguous run that starts anywhere and wraps, and the far lanes sit 6–10 px apart - narrower than a car - so a vehicle there cannot be attributed to one lane. Built, gated behind a flag, not printed.
  • The text lags the picture. Frames reload every 15 s, the detector runs every 10 min, and a deploy takes 3–6 min, so the sentence can sit up to ~19 minutes behind the photograph next to it. Boarding lasts about twenty minutes, so a state that only exists during boarding is mostly invisible at that lag. The page now polls a small JSON file instead of waiting for a rebuild; moving the snapshot to object storage closes the rest.
  • Nobody can label these frames but the people standing in the queue. So the page asks: three buttons - “doesn't match the picture?”, “I'm here”, and did you get on. Those reports are re-measured against their own frame every night at 04:40, and a routine at 05:30 makes exactly one change, because two changes cannot be told apart in the next day's digest.

What I would not do again

  • Explain a bad number with physics before trying a second model. Weeks.
  • Validate on the interesting frames. The boring middle is where a measure is actually asked to discriminate, and it is exactly what gets dropped when frames are picked by eye.
  • Publish a quantity because it is easy to compute. The vehicle count was never wrong in an interesting way. It was the wrong question, and the hedge underneath it did not rescue it.
  • Assume one camera holds both ends of a measurement. Building two stages inside one frame produced a green→yellow interval of exactly one minute in almost every cycle - two halves of one short stretch of road, lit at once by a single car crossing it.

The site

croatianferries.com is free, has no cookies and no third-party requests, and answers one question: when does the next ferry leave, and in how many minutes. On Merag it also shows you the light, the frame it was read from, and the time it has been that colour.