Repository navigation
v0.31.0
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
GrabFrameAsynconVirtualCamorVideoCaptureCam(directly or throughReconnectingCam) while continuous
acquisition may be running, callStopContinuous()first or catchInvalidOperationException— it now throws every time. - If more than one thread grabs from one
VideoCaptureCam, serialize the grabs, or catchInvalidOperationExceptionand retry
after the otherGrabOnehas returned — the second grab is now refused at once. - If you hand-write subscribe →
GrabOne→ wait, issue the next grab or start afterGrabOnereturns, not from the handler. - If you implement
ICamand yourGrabOnecan throw after publishing its frame,GrabFrameAsyncnow 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
FrameAcquiredhandler that subscribes while aGrabFrameAsynccall 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
GrabOneto 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
FrameAcquiredsubscriber 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
GrabOnereturns rather than the moment the frame arrives
— usually microseconds later; with a slowFrameAcquiredsubscriber registered after the call started, up to the deadline. - Measured on the 0.30.1 content, on a
VirtualCamreading a folder of large images (3400×2400, about 60 ms to decode): the next
GrabOneorStartContinuousissued 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. ICamGrabAsyncnow states the same promise as a contract: when the task completes, the grab is over.GevCamkeeps 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
nullor a cancellation it cannot stopGrabOne.ReconnectingCamkeeps 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:VirtualCamat 1000 fps
returned 10 and 8 live frames (two runs) → 0; aVideoCaptureCamreading a 1920×1080 file without pacing, with calls issued
back to back, returned 136 and 141 → 0. Every call now throwsInvalidOperationException("Continuous acquisition is running…"). - What you may notice:
GrabFrameAsyncon 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:VirtualCamthrew on most calls, and so did aVideoCaptureCamreplaying 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
VideoCaptureCamwhose reads fill most of each interval returned a live frame on most calls, and threw or returnednullon
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.
GevCamalready threw.
- Third-party
ICamimplementers: if yourGrabOnepublishes its frame and then throws before the call's deadline, the
genericGrabFrameAsyncnow throws that exception instead of returning the frame. AGrabOnethat 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
GrabOnehas started is not taken (GrabOnepublishes a frame that comes after
the call). A call whose token is cancelled beforeGrabOneruns always ends asOperationCanceledException. - 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
OperationCanceledExceptionwithGrabOnenever 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
VideoCaptureCamread): 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…".StartContinuousstill 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 400VirtualCampairs). - 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;CropTooutput 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,PointsandHudare 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
ViOverlaythat
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.IsHudwith a corner alignment — the same testClipOverlayToImageandViOverlay.CropTouse), shapes and
lines, and the name tags ofShapeskeep their size. For bottom alignments the gap below the text grows with it. With
ClipOverlayToImageon, 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 default1.0without 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: whenFrameAcquiredfires, the one-grab mark is still up — the handler and later subscribers are still running
insideGrabOne, andGevCamstill 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 afterGrabOnereturns, or useGrabFrameAsync.CamGrabExt.GrabFrameAsyncand the Imaging README: when the call returns, what it waits for and up to when, that a throwing
GrabOnemakes it throw, and the remaining pairing limits (all three cases in theGrabFrameAsyncsummary). There and in
ICamGrabAsyncandReconnectingCam.GrabFrameAsync: thatReconnectingCamkeeps 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-incrementalreports 0 warnings (an incremental build reports none when it recompiles nothing) -
mainis an ancestor of this branch (the release-pr-guard job verifies it) - the tag will be created on the merge commit,
mainmerged back intodevafterwards, anddevbumped to the next-devversion