Skip to content

Releases: cmir79/CvInspect

v0.29.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 03:40
4906512

What changed and why

Two behaviour changes in the vision tools (a safer blob default, and a ring-fill counting rule), fixes to the single-grab
path, and GevSharp 0.4.1. Saved recipes are not rewritten by anything in this release — but a taught Bright ring-fill
recipe reads slightly differently (see below).

Take this release if you grab from GigE cameras: earlier versions could return a frame the camera had cut short, as
a normal grab result with its bottom rows from an older frame. This was measured on hardware; see "Imaging.Gev" below. With 0.29.0 such a
grab fails instead.

Library — blob: CvBlobOpt.UseOtsu now defaults to false (behaviour change)

Otsu always splits the histogram in two. When the search region holds only one population — the part is absent and
the region is plain background, or the part fills the whole region — it splits the noise or the lighting gradient,
and the half-lit pixels join up (8-connected) into one large blob that MinArea does not filter out. Measured with
this package on synthetic images (200×200 region, background 30 / part 220, uniform noise ±5 and ±10, gradient ±20):

  • Part present (including a part covering 0.8 % of the region, and a part on a gradient): automatic and fixed
    thresholds both right.
  • Part absent: the automatic threshold returned one blob of 42–54 % of the region. The fixed threshold returned none.
  • Part filling the region: the automatic threshold reported about half the area (42–54 %).
  • A band-coverage check shaped like a real one (40×628 band, dark polarity, median 3, MinArea 50, pass at ≥ 20 %)
    read a band with its target missing as 49–66 % — a pass. A fixed threshold of 100 read 0 %.

A blob is mostly used to confirm that something is there, and the automatic threshold fails in the "something is there"
direction, so the default is now the fixed threshold.

  • The default does not change saved recipes. A serializer that writes every property (System.Text.Json with default
    settings, for one) always writes UseOtsu, so a recipe saved before 0.29.0 keeps the value written in it (usually true). Presence and area recipes must be edited: turn Otsu off
    and set Threshold.
  • What the default does reach: options built in code with no saved file (for example a tool not taught yet), and files
    that lack the UseOtsu key (read as false).
  • Threshold (128) is a placeholder, not a calibrated value. Set it between the part and background levels, and check that
    an empty sample yields no blob. Bright means pixels above it; Dark means pixels at or below it.
  • For locating only (position, not presence), Otsu tolerates lighting drift — turn it on explicitly there.

Regression 9-E: a background-only region yields no blob with the default. In the same run, turning Otsu on for the
same image invents a blob of about half the region (the control), and a present part is still found with the default.

Library — ring fill: Bright counts pixels above the threshold (fix + behaviour change)

CvRingFill counted Bright as value >= threshold. The Otsu value OpenCV returns is the top of the lower class, and its
binarization treats > threshold as foreground, so >= counted one whole bin of the dark class as filled. On a
noise-free two-level image (30 / 220) Otsu returns 30, and a half-filled band read 100 %, as did an empty band
on a bright background. With ±5 noise it added about one bin in eleven. Dark (<=) was right all along.

  • Bright now counts value > threshold, for Otsu and for the fixed threshold. The fixed-threshold rule now matches the blob
    tool (Cv2.Threshold).
  • What this costs: a taught Bright ring-fill recipe reads lower by the pixels whose value is exactly the threshold. A part
    near the pass limit can flip. Dark recipes do not change.
  • Regression 9-D: a half-filled two-level band reads about half with Otsu, Dark on the same image is the control, and the
    two polarities must add up to 100 %. A liveness check pins that Otsu returned the dark value, which is what makes >=
    visible there. Putting >= back fails with RatePct = 100, ThresholdUsed = 30.
  • Both tools now document the one-population limit of Otsu where it is chosen (option descriptions and XML docs). For ring
    fill it reads about half (54.5 % Bright, 45.5 % Dark) when an empty or full band is as bright as its surroundings.
    Computing the threshold from the band pixels alone would make that worse.

Imaging — single grab

  • GevCam: AcquisitionStart is now sent inside the try whose finally sends AcquisitionStop. A GVCP command is on
    the wire before its acknowledgement is awaited, so a caller that cancelled while AcquisitionStart was being acknowledged
    left the device acquiring, with no Stop and no frame-number reset. On a camera that declares the acquisition-mode lock
    through a register (Crevis), the next StartContinuous could then stall after one frame. Hardware-dependent; there is no
    suite test for it.
  • GevCam.GrabFrameAsync(Timeout.InfiniteTimeSpan) now uses GrabTimeoutMs (default 5000 ms), as the ICamGrabAsync
    contract says ("defer to the implementation's own deadline"). It used to mean no deadline at all, so a camera waiting for
    its trigger could hold the call until the camera was closed or the token cancelled. Very large timeouts are clamped, and
    zero or negative ones become 1 ms. Regression 10-G-9.
  • The generic path (CamGrabExt, for cameras without their own async grab) with InfiniteTimeSpan: if GrabOne returns
    without a frame, a late frame now gets a one-second grace and then the call returns null. It used to wait forever,
    for example after a VideoCaptureCam read failure. VirtualCam publishes before returning, so it was never affected.
    Out-of-range timeouts are normalised before the grab starts. Before, Task.Delay threw
    ArgumentOutOfRangeException after GrabOne was already running. The waits no longer leave a registration on the
    caller's token. Regressions 10-G-10 and 10-G-11.
  • A single grab that ends without a frame (timeout or cancellation, camera still connected) opens a short stop window, so a
    block its stop cuts is logged as "in flight when acquisition stopped" (Info) rather than a dropped-frame warning. The next
    grab closes that window when it starts acquisition, so a genuine loss in the next grab still warns — even in a loop that
    retries or polls with a short timeout. A grab that received its frame, or ended because control was lost, opens no window.
    Regression 10-G-12 covers the window rule, and the hardware wiring is outside the suite. The TimeoutException text
    now names both places to look: trigger mode on the camera-state line, and a separate chunk-mode warning.
  • Documentation of ICamGrabAsync corrected to match what GevCam has always done: a timeout is null on the
    generic path but a TimeoutException from GevCam, whose message points at what it logged at open. null can arrive before the
    timeout (a frame in a pixel format that cannot be converted). A camera that is not open throws. Transport failures may
    surface as the backend's own exceptions. Code that must work on any backend treats null and TimeoutException alike
    as "no frame".
  • Regression 10-G-8 pins that a grab cancelled before it starts leaves no "already waiting" mark. GevCam sets the mark
    inside the grab body, so it was never affected. The test guards the ordering.

Imaging.Gev — GevSharp 0.4.0 → 0.4.1

GevSharp fixed four items this package reported (its release notes: cmir79/GevSharp#2). What
consumers of this package will see:

  • Frame completion: a block that ends short of what its leader announced is now incomplete, instead of being handed on
    with the previous frame's bytes as its tail. IncompleteFrames goes up by one and MissingPackets by the part that never
    came, and the GigE library warns once per open the first time it happens. Crevis and other models were not measured
    with this rule.
    If every frame of one pixel format is dropped, with GevSharp's warning "the trailer ended the block …
    the leader announced … bytes", the computed frame size does not match what the device sends for that format.
  • New symptom, and why it is the fix: a grab right after a cancelled grab can now time out. Measured on the Basler
    below, by cancelling 20 grabs 0–3 ms after the call and then grabbing normally, eight times. In 2 of the 8 bursts the
    camera also cut the next grab's own frame — trailer after 551–552 of 563 packets. It did so with GevSharp 0.4.0 and
    0.4.1 alike. With 0.4.0 (CvInspect ≤ 0.28.0) that grab returned the cut frame as its answer: it arrived in 71–75 ms
    instead of 78–85, and its bottom rows were left over from an earlier frame. With 0.4.1 the frame is incomplete and the
    grab ends in TimeoutException with a frame N dropped: Incomplete warning. That warning is correct, because it is that
    grab's own frame. A retry succeeds (the following grabs were normal in every case). Why the camera cuts the frame after
    a cancelled grab has not been isolated. A grab-sequence change to avoid it is planned for a later release. Hosts that
    cancel grabs in flight (a loop token) are the ones exposed; plain timeouts cut nothing in these runs.
  • Float features (SwissKnife/Converter formulas) are evaluated in floating point. On Crevis, gain, delay, temperature
    and frame rate now read their correct values. The exposure GevCam reads and writes is unchanged on both known models.
  • Writes that fail after reaching the device now drop the cached value. The case this fixes: an AcquisitionStop
    whose acknowledgement was lost could leave a Crevis camera's acquisition mode locked in the cache.
  • ReceiveAsync lifetime is documentati...
Read more

v0.28.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 07:48
de2b392

What changed and why

Two additions that finish the "show a result on a cut-out" work of 0.27.0, plus one fix to the HUD line colours.

CvDispCtrl.ClipOverlayToImage — keep the result overlay inside the image

Opt-in; the default is the old behaviour.

0.27.0 added ViOverlay.CropTo for showing a result on a cut-out, and left clipping to the renderer. CvDispCtrl
clipped the overlay only at the edge of its display area, not at the image. A cut-out rarely has the display's
aspect ratio, so the fit leaves margins, and lines crossing the cut plus items outside it were drawn in those margins,
as if the image went on. A consumer measured it on 0.26.3 (the rendering code is unchanged in 0.27.0). The run was
offscreen at 96 DPI, with a Mono8 frame and the consumer's overlay: lines crossing the cut, and a polygon and a label
outside it. Results:

  • A 400×150 frame fitted into a 400×400 display put 6305 overlay pixels in the margins above and below.
  • 150×400 put 4582 in the side margins.
  • A matching aspect ratio still put 56 there, because the fit keeps a small border.
  • Geometry wholly inside the image put none.

How much bleeds depends on what is drawn. A wider margin only leaves more room for it.

  • ClipOverlayToImage = true draws Overlay only inside the rectangle the image occupies on screen.
  • Two things are not clipped to the image. The corner summary HUD (ViOverlayLabel.IsHud with a corner
    alignment, the block CropTo puts back in its corner) is one. Its text keeps its screen size, so on a small cut-out
    the block is larger than the image as drawn, and clipping it would leave only the header line — which looks like a
    complete one-line HUD. With the option on, that HUD is drawn after the other items, so it sits on top. Editable
    Shapes are the other, so a handle on the image border can still be grabbed. Both are still clipped at the edge of
    the display area.
  • What turning it on costs: every other item is cut at the image edge, labels included. Label text keeps its
    screen size, so the smaller the image is drawn (a small tile, zoomed out), the more of an edge label is lost.
  • The default stays false: the overlay runs to the edge of the display area, and a label on the image border stays
    readable where the fit leaves a margin on that side. At the display's own aspect ratio that margin is only a few
    pixels, so such a label is mostly cut either way.
  • Turn it on where a cut-out is shown (a result view fed by ViOverlay.CropTo and CamFrame.Crop). The docs of
    ViOverlay.CropTo and ViOverlayLabel.IsHud and the core and Imaging READMEs now point to it.
  • Note on 0.27.0: its notes said renderers ignore ViOverlayLabel.IsHud. CvDispCtrl now reads it, but only
    while ClipOverlayToImage is on, as described above. With the option off, nothing changes.

Regressions:

  • ClipOverlayToImageKeepsTheFitMarginsClean covers top/bottom (400×150) and side (150×400) margins. In the same run,
    the default draws in the margins (the control), and the option leaves them clean while drawing exactly the same
    pixels inside the image.
  • ClipLeavesTheCornerHudAndEditShapesWhole checks the HUD on a thin strip: it stays whole with the option on. A label
    with the same text and position but no HUD mark is clipped (the control). Edit shapes are not clipped to the image.

ViOverlayLabel.Hud — the summary HUD as data

A screen that shows the HUD apart from the image (a text panel beside a process monitor) had to parse the label's
text — "[OK] title\nline…" — for the verdict and the lines. The text is built for drawing: its format and colour
can change (translated wording, for one). A verdict read back from it would then show an OK in the NG colour, and
nothing would look broken.

  • ViHudSummary(bool IsOk, string Title, IReadOnlyList<(string Text, ViOverlayColor? Color)> Lines, ViHudPos Pos) —
    what ViHud.AddSummary was given. Every AddSummary overload sets it on the label it adds. A label that is not a
    summary, or that was built by hand, has Hud == null. IsHud (a corner-anchored summary) and Hud (its content)
    are separate.
  • Lines are the detail lines as given, without the title line. An entry may contain a line break, so do not count
    drawn lines from it. A null colour means the verdict colour.
  • The summary keeps its own read-only copy of the lines, whichever way it is built. Equality compares the lines'
    content. CropTo keeps the same summary.
  • It is a value for code to read, not a storage format. The tuple element names do not exist at run time, so
    System.Text.Json needs IncludeFields (or a converter), and Json.NET writes Item1/Item2 — the same as
    ViOverlayPoly.Points.
  • The label's Text is unchanged, so hosts that parse it keep working.

Fix: HUD line colours when a coloured line contains a line break

In the coloured AddSummary overloads, a coloured entry containing \n got one colour slot for two drawn lines. Every
following line's colour then shifted by one, and the last fell back to the verdict colour. This has been so since the
overload was added. Each drawn line of the entry now gets its colour.

Version

0.28.0 — minor. Added: CvDispCtrl.ClipOverlayToImage / ClipOverlayToImageProperty (CvInspect.Wpf),
ViHudSummary and ViOverlayLabel.Hud (CvInspect). Changed: CvDispCtrl reads IsHud when the new option is on,
and the coloured AddSummary fills each drawn line's colour. CvInspect.Imaging changes only in documentation;
CvInspect.Imaging.Gev does not change (lockstep).

The version rule in the PR template and Directory.Build.props now also describes a patch exception: an opt-in
addition that completes the previous minor's feature may ship as a patch. This release does not use it. It began as
0.27.1 under that exception, and became a minor when the HUD data joined it.

Checks

  • dotnet build CvInspect.sln -c Release --no-incremental reports 0 warnings
  • 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

v0.27.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 06:30
57bdf16

What changed and why

Cropping is its own tool now — CvCropOpt with CvImageOps.Crop. CvImageProcessOpt / Preprocess only
resample and denoise.
This is a breaking change on purpose; the old call sites stop compiling.

A consumer inspects parts that are small in the field of view and sit off-centre, and wants to cut them out
before inspecting and to show the result on the cut-out. Cropping and image cleanup are different jobs, so
they are separate tools even though a chain gets one tool longer.

  • CvCropOpt — UseCrop, CropX/CropY/CropW/CropH in original-image coordinates, with its edit shape.
    The property names are the ones CvImageProcessOpt used to carry, so an old image-process file read as a
    CvCropOpt gives the crop back, and handlers keyed by property name keep working on the new tool.
  • CvImageOps.Crop(Mat src, CvCropOpt opt, out Rect used) — returns a copy and the rect it used (the whole
    image when the crop is off or falls back). A view would share memory with a CamFrame wrapped by AsMat():
    writing into it rewrites pixels that history and display still hold (a regression checks exactly this).
    The copy is one pass over the region, or over the whole frame when the crop is off.
  • CvImageOps.CropRectOf(Mat src, CvCropOpt opt) — the rect alone; same fallback as before (outside the image or
    under 8 px → whole image plus a warning).
  • CvImageOps.MapOf(Rect used, Mat pre) — the stage → original map, carrying both the crop origin and the scale.
  • Removed: CvImageOps.CropRectOf(Mat, CvImageProcessOpt) and CvImageOps.MapOf(Mat, Mat, CvImageProcessOpt).
    Keeping them as obsolete would have let a call compile against an image that was never cropped and add the
    old offset to it. CvImageProcessOpt no longer implements ICvShapeSource — it has no geometry left.

Migrating a call site:

// before
using var pre = CvImageOps.Preprocess(gray, ip);
var map = CvImageOps.MapOf(gray, pre, ip);

// after
using var cut = CvImageOps.Crop(gray, crop, out var used);
using var pre = CvImageOps.Preprocess(cut, ip);
var map = CvImageOps.MapOf(used, pre);

// no crop in this chain: pass the whole image rect
var map = CvImageOps.MapOf(new Rect(0, 0, gray.Cols, gray.Rows), pre);

There is deliberately no MapOf(Mat, Mat): a cut image does not know where it was cut from, so such an overload
would get the scale right and silently drop the offset — every result drawn off by the crop origin, with nothing
looking wrong. Only Preprocess takes cut: the result overlay stays in original-frame coordinates, so arguments
that bound drawing to the image (ViDraw clipping sizes, the corner ViHud branches) keep the original frame size.
The first parameter of Preprocess and CropRectOf is now named src (was gray) — calls with named arguments
need the new name.

Recipes saved before 0.27. An image-process file with the crop on still loads — the old fields stay on
CvImageProcessOpt as hidden, obsolete properties, and they are written back on save until the crop is moved, so
a host that saves before migrating does not lose it. But Preprocess throws InvalidOperationException while an
unmoved crop is on.
The tools after it were taught on the cut-out; running them on the whole image would put each
of them off by the crop origin and still produce verdicts that look normal. Move it when the recipe loads:

if (CvCropOpt.FromLegacy(ip) is { } moved) { /* use `moved` as the crop tool, then save the image-process file */ }

FromLegacy returns the crop, turns it off on ip and logs one Info line. A crop saved without its size keys moves
at the old default size, 640×480, as 0.26 cut it. Save the image-process file again afterwards, or the crop is
moved again on the next load. With System.Text.Json that save also drops the old keys. Json.NET keeps writing
them as false/0, which reads back as a crop that is off. A file whose crop was off loads as before. The old
properties are [Obsolete]: code that sets them gets CS0618.

Migration is one-way. Once a migrated recipe is saved, a host on 0.26.x or earlier finds no crop in the
image-process file and does not know the crop tool. It runs uncropped, with no error. Roll recipes back together
with the host, and keep a copy from before the first 0.27 save.

Teaching screens: the image-process tool no longer shows the crop checkbox or rectangle. Add a CvCropOpt tool
in front of it.

Showing a result on the cropped region

  • ViOverlayLabel.IsHud — every ViHud.AddSummary branch sets it. Renderers ignore it. Use it to find the summary
    HUD instead of guessing from the alignment and text: corner alignment is also used for labels above detection boxes.
  • ViOverlay.CropTo(x, y, width, height) — a new overlay for the cut-out. Items move by (−x, −y). The HUD goes back
    to the same corner of the cut-out, where AddSummary would have put it on that image. A HUD given a start height
    keeps that height. The source overlay is not touched, and items outside the region are kept — clipping is the
    renderer's job. A label copied by hand without IsHud moves like any other label.
  • CamFrame.Crop(x, y, width, height) — a new frame with a tight stride. It keeps the format, DeviceTimestamp and
    TimestampUtc (it is the same shot). It throws if the region is not inside the frame, because the overlay moved
    with the same rect would no longer match. Pass the used rect from CvImageOps.Crop.

Also

  • CvInspect.Wpf README, Frame row: hiding and showing the control (a tab switch, a collapsed parent) is not a
    resize. The zoom is kept unless the size changed while the control was hidden. A regression covers both cases.
  • Demo: a Crop tool. Loading a recipe folder saved before 0.27 inserts a Crop tool just above the Preprocess that
    carried the crop.

Version

0.27.0 — minor. Added: CvCropOpt (+ FromLegacy), CvImageProcessOpt.HasLegacyCrop, CvImageOps.Crop,
CvImageOps.CropRectOf(Mat, CvCropOpt), CvImageOps.MapOf(Rect, Mat), ViOverlayLabel.IsHud, ViOverlay.CropTo,
CamFrame.Crop. Removed: CvImageOps.CropRectOf(Mat, CvImageProcessOpt),
CvImageOps.MapOf(Mat, Mat, CvImageProcessOpt), ICvShapeSource on CvImageProcessOpt. Obsolete:
CvImageProcessOpt.UseCrop/CropX/CropY/CropW/CropH. Changed: CvImageOps.Preprocess no longer crops and
throws on an unmoved crop; its first parameter is named src. CvInspect.Imaging.Gev has no change of its own.

Checks

  • dotnet build CvInspect.sln -c Release --no-incremental reports 0 warnings
  • 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

v0.26.3

Choose a tag to compare

@github-actions github-actions released this 21 Sep 06:32
cf2e6af

What changed and why

CvDispCtrl refits the moment its size changes — it no longer waits for the next frame.

On a screen where frames are rare (a monitoring display, a stopped line), resizing the window left the
picture at the old scale until the next frame arrived: margins when the window grew, cropping when it
shrank. It was reported from a production display — a borderless full-screen window whose monitor was
unplugged and plugged back in. Live views hid it because the next frame refitted within milliseconds.

The display surface reserved a refit when its size changed but did not ask to be redrawn. WPF draws a
resized element during arrange and delivers the size-change notification only after layout finishes
(checked against the WPF 8.0.31 runtime source), so the reservation always arrived after the draw and
sat there until something else drew.

The same cause had a second symptom: whatever drew next consumed the reservation — including the
first mouse-wheel zoom after a resize, which snapped back to fit instead of zooming.

Measured with a 1000×500 frame and no new frame after the resize:

0.26.2 0.26.3 fit at the new size
grow 800 → 1400 px wide 776 px (old scale) 1126 px 1126 px
shrink 1400 → 800 px wide 663 px (old scale, right side cropped) 776 px 776 px
one wheel notch out after growing 1126 px (zoom lost — the picture got larger) 901 px (= 1126 / 1.25) —

The regression ResizeRefitsWithoutANewFrame covers both directions; it fails on 0.26.2 and passes here.

If your host worked around this: invalidating the display from a parent SizeChanged handler does
not help — size notifications go to the outermost element first, and when a handler requests a redraw
WPF stops delivering them and re-runs layout, so the surface redraws before its own reservation exists.
Deferring the invalidation to Loaded priority does work. Such a workaround stays harmless on 0.26.3
(measured by a consumer) and can be removed.

Behaviour change to be aware of. Resizing always meant "refit"; what changes is when. Before, the
refit waited for the next draw, so a manual zoom survived a resize until the next frame or interaction.
Now, if the display area changes size — a side panel collapses, a banner appears above the display,
the toolbar is toggled — a manual zoom returns to fit immediately. Hiding and showing the control (a tab
switch, a collapsed parent) is not a resize: the zoom is kept unless the size changed while it was hidden.

Also

  • GevCam logs discarded N queued frame(s) up to frame M before grabbing a fresh one at Info
    instead of Debug. It is written when a single grab finds leftover frames in the queue and drops them
    before starting — the situation 0.26.1 failed in — so a successful grab right after that line shows
    the 0.26.2 fix holding. At Debug it never reached a file log filtered at Info, and a field check could
    not tell "met the situation and passed" from "never met it". It shows up mostly on the first single
    grab after live view was stopped, and not on every such grab (the stop itself usually clears the
    queue). Two limits: frames that arrive after the drain and are rejected by the start-line check are
    still dropped without a log line, so a missing line does not prove nothing stale was met; and the
    similar line written when continuous acquisition stops (… left in the queue when acquisition stopped)
    stays at Debug.
  • Comments in GevCam were corrected where they overstated: the stale-frame fix of 0.26.2 relies on
    the device clock, and on a camera that provides no clock (grabKey=frameId in the open log) and
    restarts its frame numbers, the 0.26.1 failure path is still open; which layer rejects an off-grid
    exposure; when an exposure read-back can be an echo of the value written. No code change.
  • CvInspect.Wpf README: the Frame row now says a resize refits and replaces a manual zoom.

Version

0.26.3 — patch. No public API change: one display defect fix, one log-level change, comments.

Checks

  • dotnet build CvInspect.sln -c Release --no-incremental reports 0 warnings
  • 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

v0.26.2

Choose a tag to compare

@github-actions github-actions released this 18 Sep 00:09
b0bdb5c

What changed and why

A single grab now recognises its own frame by a clock we set, not by a number the device owns.

GrabOne has to tell the frame it asked for from one that merely turned up — a leftover from an
earlier acquisition. It did that by frame number: accept only a frame newer than the last one
emitted. That works only while the numbering keeps running, and the device decides when it
restarts
. Two cameras, two answers, both measured:

frame ids, stopping after each grab not stopping
Basler acA2500-14gm 1, 2, 3, 4 5, 6, 7, 8
Crevis MG-A320K-35 (fw 3.6.2.9) 1, 1, 1, 1 1, 2, 3, 4

Since 0.25.2 every grab ends with AcquisitionStop, so on the second camera every grab starts a new
numbering epoch. 0.26.1 papered over that by discarding the baseline at each stop, which fixed the
common case and left one hole: the queue drain runs before the next AcquisitionStart and re-armed
the baseline with a number from the previous epoch, so any grab that found something to drain
could fail the same way.

The case that settles it, measured on the line: a grab whose stale frames carried ids 1, 2, 3, 4
and whose own frame carried id 1. No comparison of numbers can separate those.

So the grab now latches the device's own clock immediately before AcquisitionStart and discards
any frame stamped earlier than that line. There is no threshold and no assumption about numbering.
The registers used — GevTimestampControlLatch and GevTimestampValue — are standard GigE Vision
bootstrap registers, not a vendor extension. A camera that supplies no usable clock falls back to the
previous frame-number behaviour, unchanged.

Three premises were measured on both camera families before relying on them:

  • The frame header's stamp and the latched register are the same clock. Latch, grab, latch again:
    the frame's timestamp lands between the two latches.
  • It is not a time-of-day value that would wrap at midnight. The observed counters held 13.6 days
    (Basler, 125 MHz ticks) and over a day (Crevis, 66.7 MHz) since their epoch.
  • It actually rejects a stale frame. With an old frame deliberately left in the queue, the latch
    separates it from the new one in a single comparison.

One thing worth stating plainly, because it changes how this fix can be maintained: the failing
scenario can now be reproduced on any camera.
A stale frame sitting in the queue needs no
renumbering device to create, so the guard can be exercised here rather than only where the defect
happened to show. The old guard could only be tested on hardware that renumbers.

The camera-state line logged at open now ends with grabKey=deviceClock or grabKey=frameId, so the
log says which rule a given camera is actually running under.

Also

Four XML-documentation warnings are fixed — three of them introduced in 0.26.0, including a cref to
a member that does not exist. They were missed because the build output was being filtered and
because an incremental build reports no warnings when it recompiles nothing.

Version

0.26.2 — patch. No public API change; one defect fix plus diagnostics.

Checks

  • dotnet build --no-incremental in the default configuration reports 0 warnings
  • 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

v0.26.1

Choose a tag to compare

@github-actions github-actions released this 17 Sep 07:16
5d4e50e

What changed and why

Take this release if you are on 0.25.2 or 0.26.0 and use a GigE camera.

0.25.2 made every single grab issue AcquisitionStop when it finished. That fixed a real defect — a
camera whose acquisition mode stayed locked for the rest of the session — but it broke single grabs
outright on any device that restarts frame numbering after acquisition stops.

GrabOne discards a received frame that is not newer than the last one emitted; that is how it
refuses a stale frame left over from an earlier acquisition. The baseline it compares against was
reset in exactly one place: when the stream is opened. Once every grab ended with a stop, a device
that begins numbering again from the start would hand the next grab a frame that looks older, so
the filter threw it away and waited for a number that was never coming, until the deadline expired.
Only the first grab of a session worked, because its baseline is still zero and the filter is off.

Measured on the line by a consumer — Crevis MG-A320K-35, firmware 3.6.2.9, two cameras: 1 of 30
grabs succeeded
on 0.26.0 against 30 of 30 on 0.22.0. The camera was not at fault and the
health counters say so: CompletedFrames = 30, no losses. The device sent all thirty frames and the
stream received them. We discarded them.

The fix is to drop the baseline wherever acquisition stops — in the single grab's teardown and in
StopContinuous. A stop is precisely the event that makes the comparison meaningless: continuity of
numbering across it is not guaranteed, and some devices do restart. Without a trustworthy baseline
the next frame is accepted whatever its number, which is what the first frame of a session already
does. The dropped-frame counter is cut at the same point, since a gap that spans a stop is not a
loss.

Continuous acquisition was never affected: the pump does not stop between frames, so numbering
continues.

Why this was not caught before release. 0.25.2 shipped with an explicit note that its fix could
not be verified on the camera available here — a Basler acA2500-14gm, which does not lock the
acquisition mode and does not restart numbering, so both the original defect and this regression are
invisible on it. That note was accurate about what had been measured and still understated the risk:
the thing the camera could not show turned out to be not the fix working, but the fix breaking
something else. The corrected build was run against the same camera to confirm it changes nothing
there (four grabs, four frames).

Version

0.26.1 — patch. No public API change; one defect fix.

Checks

  • dotnet build in the default configuration reports 0 warnings
  • 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

v0.26.0

Choose a tag to compare

@github-actions github-actions released this 17 Sep 05:13
5dde6d1

What changed and why

Taking a single frame no longer means writing the plumbing yourself.

ICam.GrabOne() publishes through FrameAcquired and nothing else, so a caller who wants one frame
has to subscribe, call, wait and unsubscribe. Every consumer had written that by hand — two of them
independently, both taking a TimeSpan. That is the signal that the library was missing something,
not that the consumers were doing it wrong.

using CvInspect.Imaging;

var frame = await cam.GrabFrameAsync(TimeSpan.FromSeconds(2));
if (frame is null) { /* nothing arrived in time */ }
else using (var mat = frame.AsMat()) { /* inspect */ }

It is an extension method on ICam, so no implementation has to change — not the six in this
repository, nor anyone else's. Cameras that can do better than the generic path advertise it by
implementing ICamGrabAsync, and the extension calls them instead.

Why not a default interface member. That was the first shape proposed, and it would have been
simpler. It is also unusable here: these packages target netstandard2.1 so they can be consumed
from Unity, and Unity's IL2CPP cannot execute default interface implementations — the build fails
outright. An extension plus an opt-in interface gives the same "no implementer churn" without asking
anything of the runtime.

null versus an exception is the contract. null means no frame, and no reason to give: the
timeout expired, or the implementation can never answer and has already said why. Anything that
should be able to answer and cannot — closed, disposed, control lost, continuous acquisition
running, another grab already waiting — throws, exactly as GrabOne does. This is not a new
boundary; it is the one ICam already drew between DeadCam (warns, never throws) and
ReconnectingCam during a reconnect gap (throws, because "silently ignoring it leaves the caller
waiting forever for a frame").

The generic path waits out the timeout rather than guessing. An earlier draft closed with null
as soon as GrabOne returned without a frame, on the assumption that publication always completes
before the call returns. That holds for the backends here and it does not hold in general — a
backend whose frames arrive on a vendor callback can publish just after returning, and the shortcut
would have turned a good grab into a null. So the deadline governs, and it runs from the call, not
from when the grab returned. The cost is that a camera which will never answer now costs a full
timeout; a camera that knows it will never answer should implement ICamGrabAsync and say so at
once, which DeadCam does.

Two things make a camera worth implementing ICamGrabAsync for, and they are different:

  • It can tell which frame is this grab's — the generic path takes whatever arrives while it is
    subscribed. Only the implementation, holding the device's own pairing evidence, can do better.
    GevCam does it by frame id.
  • It knows no frame is coming — DeadCam answers immediately instead of stalling every grab.

GevCam, ReconnectingCam (which forwards to its inner camera so the inner one's guarantee
survives) and DeadCam implement it.

Alongside, GevCam's single grab now honours the caller's cancellation token — previously an
async caller could cancel and still wait out the full timeout — and its per-call timeout overrides
GevCamOpt.GrabTimeoutMs, because the call site knows more than the configuration did.

Fewer warnings on the way in

Applying an exposure that is not on the camera's grid is no longer reported as a warning. It is
unavoidable quantization: the device refuses everything off-grid, so any configured value that is
not a multiple of the step goes through this path — measured on a Basler acA2500-14gm with a 35 µs
grid, requests of 50, 200, 1000, 5000 and 12000 µs all land on 35, 210, 1015, 5005 and 12005. Those
are ordinary field values, and warning about them on every open and every reconnect is exactly what
this library's own notes say not to do: a warning that fires all the time stops anyone reading the
warning that counts. The applied value is still reported, and genuine failures — a snap that is
refused, a rejection whose grid cannot be read, a missing node — remain warnings.

The readback that follows the write now compares against what was written rather than what was
requested, so a grid snap is not reported twice; and the level splits there. A camera holding a
different value than the one we wrote, without having refused the write, is not quantization — that
is a warning. No threshold was invented: the reference is the value we wrote.

Version

0.26.0 — minor. ICamGrabAsync and CamGrabExt are new public API; nothing was removed or changed
in a breaking way.

Checks

  • dotnet build in the default configuration reports 0 warnings
  • 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

v0.25.2

Choose a tag to compare

@github-actions github-actions released this 17 Sep 01:41
cfd0ec0

What changed and why

A single grab never told the camera to stop.

GrabOnceAsync wrote AcquisitionMode=SingleFrame, issued AcquisitionStart, took its frame and
returned. AcquisitionStart locks AcquisitionMode until AcquisitionStop, and the only
AcquisitionStop calls in the backend were in Close and in StopContinuous (and that one only
when the pump had been running). So the first single grab of a session locked the acquisition mode
for the rest of it
, and every grab after that logged a rejected write.

This was reported from a running line with the counts to match: 258 warnings in a day — 260 grabs
minus one first grab per camera — one per inspection grab, making up 69% of the inspection host's log
entries. Judging was unaffected on that day, because the value being written was the one already in
place, so SingleFrame held and all 260 grabs succeeded. The cost was the noise, and this library's
own note on dropped-frame diagnostics says why that matters: a warning that fires all the time stops
anyone reading the warning that counts.
It was firing 130 times a day per machine.

There was a heavier branch behind the same lock. StartContinuous sets AcquisitionMode=Continuous
the same way; with the mode locked, that write was rejected, swallowed, and acquisition started
anyway — leaving the device in SingleFrame. It would then send one frame and stop, and the pump's
receive has no deadline (only a cancellation token), so the loop would wait forever: IsGrabbing
stuck at true, frames simply absent, and not one line in the log. The PumpEndedBySelf cleanup
added in 0.25.0 only runs when the loop exits, so it does not help here.

  • The single grab now stops acquisition when it is done, in a finally so a timed-out or aborted
    grab stops the device too — that is exactly when you want it stopped. It does not use the grab's
    cancellation token for that. The lock never forms, which closes the continuous branch as well.
  • A rejected mode change is no longer silent. Setting the mode now reports whether the write was
    attempted and refused, and the continuous path logs what follows from it: the camera keeps its
    previous mode, so continuous acquisition may deliver one frame and then wait forever. A device with
    no such node or entry is not a refusal — it simply does not have the concept — and is not reported.

What was deliberately not done

No deadline was added to the pump's receive. A camera in trigger mode can legitimately go minutes
between frames, so any threshold here would fire on correct setups, and a warning that cries wolf is
worse than no warning. The silence is what was wrong, and the silence is what was fixed. Reading the
mode back before writing it was also declined: it hides the symptom at the cost of a register read
per grab, and the cause is now gone.

Version

0.25.2 — patch. No public API change; one defect fix.

Checks

  • dotnet build in the default configuration reports 0 warnings
  • 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

v0.25.1

Choose a tag to compare

@github-actions github-actions released this 17 Sep 00:31
465aadf

What changed and why

0.25.0 made a GigE camera honest about acquisition that dies on its own: when the receive stream
folds, the camera now says so instead of leaving IsGrabbing stuck at true. That was half a fix.
The one place that exists to act on that signal — ReconnectingCam — was not listening, so
nothing actually recovered. This release closes that, and fixes two defects found on the way, one of
which 0.25.0 introduced.

If you run 0.25.0, take this one. The last item below is a process-termination path that did not
exist before 0.25.0.

  • ReconnectingCam now recovers acquisition that dies while the connection is fine. It
    subscribed to the inner camera's frames and connection changes, but not to GrabbingChanged — so
    when a receive stream folded without a disconnect, the decorator kept its "keep grabbing" intent
    set, went on reporting a healthy connection, and never restarted anything. Frames simply stopped,
    and the host had nothing to act on. Recovery is done by replacing the session rather than
    restarting acquisition on the spot: an instance whose acquisition died on its own is not known to
    be healthy, and the replacement path already carries the backoff ladder, teardown, re-attach and
    intent restore. A stop you asked for is still never undone.

  • One death is now one rebuild. Losing control raises GrabbingChanged(false) and
    ConnectionChanged(false) back to back. Counting each as its own event to recover from queued a
    second reconnect round, which tore down the freshly rebuilt session and replaced it again —
    measured three instances for one loss, with the host seeing connect/grab twice and no disconnect
    between, and frames cut in the middle. It also logged a control loss as "stopped grabbing without
    being asked", which sends whoever reads it the wrong way. Two guards: the grab notification steps
    aside when the connection is already down (that event belongs to the connection-loss path, which
    has the right log), and recovery is counted per dead instance rather than per notification, so
    the result does not depend on the order a backend raises the two.

  • The GigE receive thread can no longer take the host process down. 0.25.0 added the
    "acquisition ended on its own" notification at the end of the pump loop — outside the per-step
    guards, on a background thread with nothing above it. A subscriber that throws there unwinds into a
    thread with no handler, which ends the process. The notification is now wrapped, and the thread has
    an outer boundary that still runs the cleanup path if anything else escapes (without it, the pump
    reference would stay behind and IsGrabbing would stick at true — the very defect 0.25.0 set out
    to fix). Neither guard swallows: both log the reason, subscriber exceptions included.

  • A subscriber that throws no longer changes what the camera does. Every backend had a place
    where a host handler's exception was read as our own failure, because the catch that received it
    said something else. GevCam logged it as frame conversion failed when the conversion was fine;
    VirtualCam as emit failed; ReconnectingCam as reconnect loop failed after a reconnect that
    had actually succeeded — and on the way out it skipped resuming continuous acquisition, leaving
    the camera connected but not grabbing with nothing in the log pointing at the handler. Worst was
    VideoCaptureCam, where the exception reached the live loop's boundary and ended acquisition
    altogether
    : one throwing frame handler silently stopped the stream. Publication is now wrapped at
    every site and logged at Error with the name of the event whose subscriber threw. Nothing is
    swallowed — it is someone else's defect, but the only trace of it is on our side.

Contract

ICam.GrabbingChanged now states two expectations for implementers, both of which this release
depended on: do not let a subscriber's exception leave the publishing thread (these notifications
usually go out on a background or acquisition-layer thread, where one buggy subscriber ends the host
process), and update state before raising, so a receiver can tell "acquisition stopped" from
"the connection was lost" — ReconnectingCam counts events by instance rather than by order, so
behaviour does not depend on it, but the diagnostics do.

How this was verified

The ReconnectingCam work is covered by five regressions that need no camera, each checked against a
deliberately broken build: recovery happens, a user's own stop is not undone, one control loss
rebuilds once with the right log line, the same holds when a backend raises the two notifications in
the other order, and acquisition still resumes when a ConnectionChanged subscriber throws (with the
handler named in the log and not reported as our own reconnect failure). Removing each of the four
guards in turn fails a different assertion.

Two things worth recording about the harness itself. The fake camera did not raise GrabbingChanged
on a simulated control loss, although ICam has always required it — so the defect above was
invisible to every existing test; it now follows the contract, including notification order and when
state is updated. And the race regression only works when the backoff ladder is longer than the gap
between the two notifications: with 20 ms and 20 ms the swap completed first and the second
notification was correctly discarded as a ghost, so the test passed while proving nothing.

The receive-thread boundary needs a device and the suite does not cover it. Reproduction: subscribe a
handler that throws to GrabbingChanged, then break the receive stream during continuous
acquisition — the process survives and two Error lines remain. Before the fix the process goes down.

Version

0.25.1 — patch. No public API change; four defect fixes.

Checks

  • dotnet build in the default configuration reports 0 warnings
  • 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

v0.25.0

Choose a tag to compare

@github-actions github-actions released this 16 Sep 23:34
1d960cc

What changed and why

A sibling project running its own vision stack on real cameras handed us three defect classes it
had just hit, and asked whether the same shapes existed here. They did — in different places, with
different consequences. Cross-checking them turned up a theme that runs through this release:
calls that return as if they worked, while nothing happened. A stop that takes the host process
down. A grab that reports neither a frame nor an error. A restart that is read as "already running".
An exposure value that is accepted and discarded. None of these fail loudly; every one of them is
found later, by the wrong person, from a symptom that points somewhere else.

This release is the fix for all eight, plus the removal promised in 0.24.0.

Two changes you may notice even if nothing was broken for you

  • GigE now applies CamOpt.Flip and CamOpt.Rotation. The acquisition contract in the Imaging
    README states that delivered frames already have them applied, but nothing in
    CvInspect.Imaging.Gev read either value — only VideoCaptureCam (USB/file) and VirtualCam did.
    So swapping backends under the same recipe silently changed the image orientation: teach with a USB
    camera, set the flip, move to GigE, and that setting quietly does nothing. That is worse than a
    dead feature — someone who verified the behaviour on one backend has no reason to suspect the
    other. Nor is it only a dead feature for the person configuring the camera: if your settings UI
    exposes these properties — and a UI generated from CamOpt does — an operator can set a rotation
    on a GigE camera today and watch nothing happen, with no exception and no log line to explain it.
    The transform is applied just before publication, in the established order (rotate, then flip),
    and both values at their default None return the frame unchanged, so an installation that does
    not use them pays nothing. If you do set either value for a GigE camera, your image orientation
    changes with this release.
  • SetExposureTimeUs now sticks. It may be called before Open — the implementation holds the
    value and applies it when the device opens — and the last value given wins over
    CamOpt.ExposureTimeUs and survives a reopen. This is the rule ReconnectingCam already used when
    restoring a reconnected camera, now pushed down into the backends and written into the ICam
    contract. If you drive GevCam directly and apply exposure changes by editing CamOpt and doing
    Close/Open, you now get the value from your earlier SetExposureTimeUs call instead.

    What is remembered is the value you asked for, recorded before the device call — not the value
    the device accepted. If applying it fails, or the camera snaps it onto its own grid, the next open
    still asks for what you last requested. Remembering only successful values would mean that after a
    failed apply, a stale success beats a freshly edited CamOpt on the next reconnect: the operator
    sees "I saved it and the brightness did not change", and nobody connects that to the one failure
    line in the log. The ordering is part of the contract now, not an implementation accident.

Acquisition: silent no-ops and one fatal one

  • A stop could take the whole process down. VideoCaptureCam.StopContinuousCore waited on the
    worker with Join(100) and then disposed the CTS whether or not the worker had left. The loop
    keeps touching token.IsCancellationRequested and token.WaitHandle afterwards, and a disposed
    CTS's wait handle throws ObjectDisposedException on an unguarded background thread — an
    unhandled exception that ends the process. Exceeding 100 ms is not an exceptional case: a slow
    device read, or a file source's own pacing wait (1000/fps — 100 ms at 10 fps), sits right on it.
    The loop body is now guarded, and the CTS is released only when the worker is confirmed to have
    left. The remaining contract is documented rather than hidden: this call returning does not mean
    the loop has ended
    , and one more frame may be published after it.
  • VirtualCam's stop had no wait at all. The no-argument Timer.Dispose() does not wait for a
    callback in flight, so a frame could be published after StopContinuous returned and be picked up
    as the answer to the next GrabOne — which ICam.StopContinuous explicitly promises against. It
    now waits on a drain handle, outside the lock (the thing being waited on is your frame handler,
    which may call back into this camera), skips the wait when the publishing thread is the one asking
    to stop, and keeps the handle if the wait times out rather than letting a late signal hit a
    disposed one.
  • VideoCaptureCam.GrabOne swallowed read failures — it discarded TryReadFrame's result and
    returned with no frame, no exception and no log. For a grab a person asked for, that is
    indistinguishable from success. The continuous loop already logged the same failure.
  • A receive pump that died on its own left its reference behind. If the GigE stream closed or a
    receive failed, PumpLoop exited without cancellation, IsGrabbing stayed true forever, and the
    next StartContinuous read that as "already running" and returned silently — the camera never
    restarted and nothing said so. The pump now cleans up after itself and raises the
    GrabbingChanged(false) that ICam already promised for acquisition that stops without being
    asked to.
  • Open after a control loss did nothing. Losing control clears IsConnected but leaves the
    device reference, and Open only looked at the reference — so it returned as though the camera
    were already open while the caller waited for frames that were never coming. It now folds the dead
    session and reopens. The disconnect was already announced at control-loss time, so no second
    notification is raised.

Display

  • CvDispCtrl kept subscriptions on shapes it was given. The shape list usually belongs to the
    host and outlives the control, so a handler left on CvEditShape.Changed pins a discarded control
    — with its back buffer, pixel array and visual tree (measured: attaching 40 controls to one shape
    list and dropping them grew the process by 799 MB, against 50 MB for the same 40 with no shapes).
    Tab and page switches are exactly the layout that does this. The control now subscribes while it is
    loaded and releases on unload, and does not re-subscribe while unloaded — a list swapped in behind
    a hidden control would otherwise revive the subscription with no Unloaded left to undo it. Hosts
    need do nothing.

Removal

  • CvInspGeom.VerifyLandmark is gone, as announced when it was marked obsolete in 0.24.0.
    Deciding whether a landmark is present is the host's policy, not the library's; the geometry
    underneath it shipped in 0.24.0 as FixturePose and the NormalizedWindow overloads. The only
    caller moved its 8 call sites onto those primitives and reported zero remaining calls; the other
    two known consumers never called it.

How this was verified

Honest accounting, because it is uneven. The GigE mount transform has a regression that was run
against a deliberately broken build (asymmetric marker, flip/rotate/identity/timestamp). The rest of
the acquisition work is device-dependent and the suite does not cover it — those fixes rest on the
documented contract, on reading the mechanism through, and on reproduction procedures recorded with
each commit. One regression written for the VirtualCam drain was removed rather than kept: it
passed against the unfixed build too, because publication is already complete by the time a handler
can slow it down. A test that passes without proving anything is worse than no test.

Version

0.25.0 — minor. CvInspGeom.VerifyLandmark was removed from the public API.

Checks

  • dotnet build in the default configuration reports 0 warnings
  • 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