Skip to content

v0.31.0

Choose a tag to compare

@github-actions github-actions released this 13 Oct 06:30
· 12 commits to main since this release
5ac7758

What changed and why

Two public API additions make this a minor release: copy constructors on the overlay item types, and
CvDispCtrl.OverlayLabelScale. The rest are fixes to the generic GrabFrameAsync path and to VideoCaptureCam, plus one crash
fix in CvDispCtrl: a label with FontSize 0 no longer throws from OnRender. In the Imaging fixes, a call that should have
failed or waited returned instead, and in three of them the caller received a frame that was not its own grab's, with no error.
Four sections are marked behavior change, and each has a "What you may notice" line.
11 of the 21 new tests pin the Imaging fixes and fail against 0.30.1's Imaging code; 6 exercise the new API (2 do not compile
against 0.30.1, and 4 fail there because the copy constructors are missing); the other 4 pass before and after — three pin parts
of this fix, and one guards a test's own type scan.

Take this release if you call GrabFrameAsync on VirtualCam or VideoCaptureCam (directly or wrapped in
ReconnectingCam) while continuous acquisition may be running, or follow it with another grab or a start; grab from more than
one thread on a VideoCaptureCam; show overlay labels whose FontSize can be 0; copy overlay items to change one property; or
want larger result text on one screen only. GevCam users are not affected by the Imaging fixes: GevCam implements
ICamGrabAsync and does not take the generic path.

Upgrading from 0.30.1:

  • If you call GrabFrameAsync on VirtualCam or VideoCaptureCam (directly or through ReconnectingCam) while continuous
    acquisition may be running, call StopContinuous() first or catch InvalidOperationException — it now throws every time.
  • If more than one thread grabs from one VideoCaptureCam, serialize the grabs, or catch InvalidOperationException and retry
    after the other GrabOne has returned — the second grab is now refused at once.
  • If you hand-write subscribe → GrabOne → wait, issue the next grab or start after GrabOne returns, not from the handler.
  • If you implement ICam and your GrabOne can throw after publishing its frame, GrabFrameAsync now throws that exception
    instead of returning the frame.
  • If you implement ICamGrabAsync, lower your one-grab mark before the returned task completes — that is now stated as part of the
    contract.
  • If a FrameAcquired handler that subscribes while a GrabFrameAsync call is in flight is slow, or marshals synchronously to a
    thread that is blocked on that call, the call now waits for it, up to its deadline.

Imaging — the generic GrabFrameAsync returns only after GrabOne has returned (behavior change)

The generic path (VirtualCam, VideoCaptureCam, and ReconnectingCam over either, which hands the call to its inner camera)
returned the moment its subscription received a frame. The frame is published from inside GrabOne, so GrabOne was usually
still running — delivering to later subscribers and then lowering the camera's one-grab mark that 0.30.1 introduced. A
GrabOne() or StartContinuous() issued right after the call returned could be refused ("A single grab is already waiting for
its frame…" for a grab, "A single grab is waiting for its frame; continuous acquisition cannot start…" for a start) — how often
depended on how much of GrabOne was still left to run.

  • Now the call waits for GrabOne to return, up to the same deadline, counted from the start of the call (with
    Timeout.InfiniteTimeSpan, a one-second grace). If the deadline passes or the call is cancelled during that wait, the frame is
    still returned, as before.
  • Cost: a FrameAcquired subscriber registered after the call started, and slow to return, now delays the call by its own time,
    up to the deadline. If such a subscriber holds the frame past the deadline, the frame comes back at the deadline and the camera
    may still be finishing that grab.
  • What you may notice: on these cameras the call now returns when GrabOne returns rather than the moment the frame arrives
    — usually microseconds later; with a slow FrameAcquired subscriber registered after the call started, up to the deadline.
  • Measured on the 0.30.1 content, on a VirtualCam reading a folder of large images (3400×2400, about 60 ms to decode): the next
    GrabOne or StartContinuous issued 0 ms after the call returned was refused 19 of 20 times, and at 1 ms 0 of 20; with the
    test pattern or a video file, 0 of 20. With this release, 0 of 20 in each of these rows. With a subscriber placed after the
    call's own that holds the frame briefly (30 ms), 20 of 20 → 0 of 20. A subscriber that holds the frame past the deadline still
    leaves the next call refused, 20 of 20 before and after — the cost described above.
  • ICamGrabAsync now states the same promise as a contract: when the task completes, the grab is over. GevCam keeps it on
    every outcome (read from the code, not run on a camera). The generic path keeps it only when it returns with a frame — after
    null or a cancellation it cannot stop GrabOne. ReconnectingCam keeps whatever its inner camera's path keeps.

Imaging — a refused grab throws, even when a frame came in meanwhile (behavior change)

If GrabOne was refused at entry — continuous acquisition running, or another single grab waiting — the generic path returned
whatever frame its subscription had picked up in the meantime: a live frame, or the other grab's frame. The refusal that ICam
requires was hidden behind what looked like a normal answer. This has been the case since GrabFrameAsync was added in 0.26.0.

  • Now the call rethrows GrabOne's exception, even when a frame is in hand.
  • Measured, 200 calls of GrabFrameAsync(500 ms) while live runs, 0.30.1 content → this release: VirtualCam at 1000 fps
    returned 10 and 8 live frames (two runs) → 0; a VideoCaptureCam reading a 1920×1080 file without pacing, with calls issued
    back to back, returned 136 and 141 → 0. Every call now throws InvalidOperationException("Continuous acquisition is running…").
  • What you may notice: GrabFrameAsync on these cameras while live runs now always throws. Before, the outcome depended on
    how much of each frame interval the live loop held the camera's lock:
    • VirtualCam threw on most calls, and so did a VideoCaptureCam replaying a file at its own frame rate when a frame takes a
      small part of the interval to decode (640×480 at 30 fps: 20–33 of 200 randomly timed calls returned a frame).
    • A VideoCaptureCam whose reads fill most of each interval returned a live frame on most calls, and threw or returned null on
      the rest. Measured on the 0.30.1 content with timeouts of 30 ms or more:
      • Reading a file without pacing: 119–186 of 200 back-to-back calls and 196–200 of 200 randomly timed calls.
      • Replaying a 1920×1080 file at its own 30 fps: 145–173 of 200 randomly timed calls (back-to-back calls mostly threw there).
      • One webcam (640×480 at 25 fps): 167 of 200 back-to-back and 197 of 200 randomly timed calls.
      • An RTSP stream reads the same way but was not run. Under heavy CPU load the share of live frames fell (15–93 of 200 in one run).
    • Code that seemed to work there during live was mostly receiving live frames, and now fails every time. GevCam already threw.
  • Third-party ICam implementers: if your GrabOne publishes its frame and then throws before the call's deadline, the
    generic GrabFrameAsync now throws that exception instead of returning the frame. A GrabOne that throws is a failure.

Imaging — frames that arrive before the grab has started are ignored (behavior change)

Between subscribing and the thread pool starting GrabOne, a live frame or a previous grab's frame could arrive and be taken as
the answer — even by a call whose token was already cancelled, which then returned that frame without ever running GrabOne.

  • Now a frame that arrives before this call's GrabOne has started is not taken (GrabOne publishes a frame that comes after
    the call). A call whose token is cancelled before GrabOne runs always ends as OperationCanceledException.
  • What you may notice: a call whose token is already cancelled now always throws OperationCanceledException (before, it
    could return a frame without grabbing), and a frame published while the call was still subscribing is no longer returned.
  • Measured with a camera that delivers a frame on subscription: on the 0.30.1 content that frame was returned every time (41 of
    41, also with a pre-cancelled token); now the grab's own frame is returned, and a pre-cancelled call throws
    OperationCanceledException with GrabOne never called.
  • What the generic path still cannot do is unchanged and documented: it cannot pair a frame with its grab structurally. Three
    narrow cases remain:
    • A grab that has not finished at the deadline: its frame is returned as is.
    • A grab that has started but has not yet passed its entry check when another grab publishes and lowers its mark. Not observed
      on an idle machine (0 of 1,200 overlapping pairs); more load would widen it.
    • A grab that returns without a frame (for example a failed VideoCaptureCam read): while the call waits for a late frame, a
      grab or a live start from another thread can be accepted and its frame taken. Read from the code, not run; present since 0.26.0.

Imaging — VideoCaptureCam refuses a second grab, or a grab during live, at once (behavior change)

VideoCaptureCam.GrabOne checked for an overlapping grab under the same lock it holds while reading a frame. A second grab that
arrived during the read waited on that lock instead of being refused, and was accepted after the first grab lowered its mark — by
then its subscription already held the first grab's frame. That caller received the other grab's frame as its own, and its own
frame reached nobody. This happened with a hand-written subscribe → GrabOne → unsubscribe too, not only with GrabFrameAsync.

  • Now the one-grab mark has its own short lock, and the overlap and live checks run before the read lock is taken. A second grab
    is refused at once with "A single grab is already waiting…"; a grab during live is refused at once with "Continuous acquisition
    is running…". StartContinuous still waits for a read in progress, as before.
  • Measured, 200 overlapping pairs of GrabFrameAsync: before, 196–200 of 200 pairs returned the same frame to both calls in every
    run on the 0.30.1 content (two clips, 1 s and 2 s timeouts); now 0 in every run — 0 of 800 across two clips and two timeouts
    (idle machine; 0 of 1,200 counting 400 VirtualCam pairs).
  • The same cause made a grab during live wait behind the live loop's reads instead of being refused at once, so a timeout shorter
    than that wait could expire first and hand back the live frame even with the previous section's fix. On the 0.30.1 content a
    grab during live returned a live frame at every timeout (162–185 of 200 from 5 to 500 ms on one clip — the previous section's
    defect). Now 0 at every timeout from 5 to 500 ms.
  • What you may notice: on VideoCaptureCam, a second concurrent grab and a grab during live throw immediately instead of
    waiting for the read in progress.

Core — copy constructors on overlay items (new API)

ViOverlayLabel, ViOverlaySeg, ViOverlayRect and ViOverlayPoly have a public copy constructor, and ViOverlayItem a
protected one for the shared properties. A changed copy names only what changes:
new ViOverlayLabel(label) { FontSize = label.FontSize * 1.5f }.

  • Why: the items are init-only classes, so a copy had to list every property by hand, and that copy silently drops any property
    added later. The library's own copies (ViOverlay.CropTo, and the Demo sample) now use the constructors; CropTo output is
    unchanged (compared byte for byte over 400 random overlays).
  • Parameterless constructors stay, so new X { … } keeps compiling. The items stay classes, not records — equality stays by
    reference. LineColors, Points and Hud are copied by reference (they are not modified after creation).

Wpf — CvDispCtrl.OverlayLabelScale (new API)

A text size multiplier for overlay labels on one control only. Default 1.0, which keeps the current rendering.

  • Use it to enlarge result text on one screen (an operator view seen from a distance) without copying the ViOverlay that
    history and other screens share — a host that rebuilds a copied overlay every frame silently falls back to normal size the
    moment one assignment site is missed.
  • Only the text size changes: the anchor and alignment stay, and the label's background box follows the text. The corner summary
    HUD (ViOverlayLabel.IsHud with a corner alignment — the same test ClipOverlayToImage and ViOverlay.CropTo use), shapes and
    lines, and the name tags of Shapes keep their size. For bottom alignments the gap below the text grows with it. With
    ClipOverlayToImage on, an enlarged edge label loses proportionally more.
  • Zero, negative, NaN and infinity are rejected: set from code they throw ArgumentException; produced by a binding they fall back
    to the default 1.0 without an exception (only the WPF binding trace says so); written as a XAML literal they fail the load with
    XamlParseException.
  • Measured offscreen: at 1.5 the drawn label and its background box each grow about 1.5×, in both the single-colour and the
    per-line-colour paths; the anchor-side edge stays within a few pixels of the anchor for top-left, top-right, bottom-centre and
    bottom-right (for bottom alignments the gap below the text grows, as above); the HUD, shapes and edit-shape tags render
    byte-identical; changing the value on a control already on screen redraws it.

Wpf — a label with FontSize 0 no longer crashes the control

Label text sizes are now clamped to a range WPF can lay out (0.5–26,000 pt) when rendering. In 0.30.1 a label with FontSize 0
or less, or above about 26,800 pt, made the text layout throw inside OnRender, which runs during layout, so in a window it
reaches the application as an unhandled exception (measured offscreen on 0.30.1: the FontSize 0 label throws during render in
both label paths; the in-window behaviour is read from how WPF runs OnRender). Without the clamp an extreme OverlayLabelScale
would do the same.

Documentation

  • ICam.GrabOne: when FrameAcquired fires, the one-grab mark is still up — the handler and later subscribers are still running
    inside GrabOne, and GevCam still has to stop acquisition. A hand-written wait that issues the next grab or a start the moment
    its handler fires can be refused — whether it is depends on timing; issue it after GrabOne returns, or use GrabFrameAsync.
  • CamGrabExt.GrabFrameAsync and the Imaging README: when the call returns, what it waits for and up to when, that a throwing
    GrabOne makes it throw, and the remaining pairing limits (all three cases in the GrabFrameAsync summary). There and in
    ICamGrabAsync and ReconnectingCam.GrabFrameAsync: that ReconnectingCam keeps its inner camera's promise.
  • READMEs: the copy constructors (core) and OverlayLabelScale (Wpf).

Tests

The core suite grows from 118 to 138 and the WPF suite from 54 to 55. The GrabFrameAsync and VideoCaptureCam tests open each
window deterministically, by holding the camera's lock, placing a subscriber after the call's own, or using a fake camera that
delivers a frame when subscribed to. Where a window has to be opened, they assert that it was open before they assert the result.
Three of them pass on 0.30.1 because they pin parts of this fix that look removable: lowering the one-grab mark when a grab throws
before Open, the second live check under VideoCaptureCam's read lock, and cancellation during the new wait for GrabOne.
Removing any one of them turns its test red. The copy-constructor test finds every item type and every property by reflection, so
a type or property added later is checked without editing the test. The OverlayLabelScale test needs the new property; its
FontSize 0 case, run on 0.30.1 without the property, throws during rendering. Five of the new VideoCaptureCam cases use a
generated MJPG clip; on Windows they fail if the clip cannot be written, but where OpenCV cannot write MJPG (possible on Linux CI)
they return early and show as passed.

Version

0.31.0 — minor. Public API was added: the overlay copy constructors and CvDispCtrl.OverlayLabelScale. The patch exception
for completing the previous minor's feature does not apply. Code that uses either addition will not compile against 0.30.x.

Checks

  • dotnet build CvInspect.sln -c Release --no-incremental reports 0 warnings (an incremental build reports none when it recompiles nothing)
  • main is an ancestor of this branch (the release-pr-guard job verifies it)
  • the tag will be created on the merge commit, main merged back into dev afterwards, and dev bumped to the next -dev version