Skip to content

Commit bd280e8

Browse files
committed
Fixes
1 parent 171eedf commit bd280e8

8 files changed

Lines changed: 510 additions & 10 deletions

File tree

src/Shiny.Maui.Controls.Camera/CameraView.Properties.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,39 @@ public partial class CameraView
6060
public static readonly BindableProperty ShowDetectionOverlayProperty = BindableProperty.Create(
6161
nameof(ShowDetectionOverlay), typeof(bool), typeof(CameraView), true);
6262

63+
/// <summary>
64+
/// Whether a frame analyzer is handed the frame with the effects on it. Default <c>false</c>.
65+
/// </summary>
66+
/// <remarks>
67+
/// <para>
68+
/// Off by default because an analyzer is usually a detector, and a detector wants the picture
69+
/// the sensor produced: a barcode reader given a Noir grade, or a face detector given an
70+
/// inverted frame, is being asked to work against the effect rather than with it.
71+
/// </para>
72+
/// <para>
73+
/// On for the other kind of analyzer - the one that is a second pair of eyes rather than a
74+
/// detector. A remote viewfinder taps the frame stream to show what the camera is seeing on
75+
/// another screen, and it should show what the local preview shows and what the captured file
76+
/// will contain, effects included. Without this it is the only surface in the app that quietly
77+
/// disagrees with the other two.
78+
/// </para>
79+
/// <para>
80+
/// It costs a render into a reusable buffer per delivered frame, and only while there is an
81+
/// effect chain to apply - with no effects, the analyzer gets the buffer straight from the
82+
/// camera either way.
83+
/// </para>
84+
/// </remarks>
85+
public static readonly BindableProperty AnalyzerSeesEffectsProperty = BindableProperty.Create(
86+
nameof(AnalyzerSeesEffects), typeof(bool), typeof(CameraView), false,
87+
propertyChanged: (b, _, _) => ((CameraView)b).RebuildEffectChain());
88+
89+
/// <inheritdoc cref="AnalyzerSeesEffectsProperty"/>
90+
public bool AnalyzerSeesEffects
91+
{
92+
get => (bool)this.GetValue(AnalyzerSeesEffectsProperty);
93+
set => this.SetValue(AnalyzerSeesEffectsProperty, value);
94+
}
95+
6396
/// <summary>
6497
/// Live color filter applied to the preview. Default <see cref="CameraFilter.None"/>.
6598
/// </summary>

src/Shiny.Maui.Controls.Camera/CameraViewHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ private protected void TeardownPipeline()
6363
[nameof(CameraView.ScanWindow)] = MapOverlay,
6464
[nameof(CameraView.Analyzer)] = MapAnalyzer,
6565
[nameof(CameraView.Filter)] = MapEffects,
66+
[nameof(CameraView.AnalyzerSeesEffects)] = MapEffects,
6667
[nameof(CameraView.Effects)] = MapEffects,
6768
[nameof(CameraView.PhotoQuality)] = MapPhotoQuality,
6869
[nameof(CameraView.VideoQuality)] = MapVideoQuality,

src/Shiny.Maui.Controls.Camera/Platforms/Apple/CameraViewHandler.Apple.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ void ApplyEffects(CameraEffectChain chain)
882882

883883
var filters = AppleCameraFilters.Create(chain);
884884
this.frameDelegate.Filters = filters;
885+
this.frameDelegate.AnalyzerSeesEffects = this.MaybeVirtualView?.AnalyzerSeesEffects == true;
885886
this.UpdateFrameDelivery();
886887

887888
// Show the filtered-frame overlay on top of the live preview while any effect is active. We must NOT

src/Shiny.Maui.Controls.Camera/Platforms/Apple/VideoFrameDelegate.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ public CIFilter[] Filters
5757
/// <summary>Set by the handler so wrapped frames carry mirroring metadata.</summary>
5858
public volatile bool Mirrored;
5959

60+
/// <summary>Whether the analyzer is handed the filtered frame - see CameraView.AnalyzerSeesEffects.</summary>
61+
public volatile bool AnalyzerSeesEffects;
62+
63+
/// <summary>Where a filtered frame is drawn before it is handed on. Created on first use.</summary>
64+
readonly FilteredFrameBuffer filtered = new();
65+
66+
/// <summary>Releases the scratch buffer with the delegate that owns it.</summary>
67+
protected override void Dispose(bool disposing)
68+
{
69+
if (disposing)
70+
this.filtered.Dispose();
71+
72+
base.Dispose(disposing);
73+
}
74+
6075
/// <summary>When set, each frame is composited with the overlay and appended to the burn-in recording.</summary>
6176
public volatile AppleVideoOverlayRecorder? Recorder;
6277

@@ -78,6 +93,19 @@ public override void DidOutputSampleBuffer(AVCaptureOutput captureOutput, CMSamp
7893
var recorder = this.Recorder;
7994
if (this.OnFrame != null && this.WantFrames?.Invoke() == true)
8095
{
96+
// The frame as the preview draws it, for an analyzer that has asked to see what
97+
// the camera looks like rather than what the sensor produced. The buffer is this
98+
// delegate's own and is reused, which is safe because the pipeline runs one
99+
// analysis at a time - see FilteredFrameBuffer.
100+
if (this.AnalyzerSeesEffects
101+
&& chain.Length > 0
102+
&& this.RenderForAnalyzer(chain, pixelBuffer) is { } effected)
103+
{
104+
this.OnFrame(AppleCameraFrame.Wrap(effected, rotation: 0, mirrored: this.Mirrored));
105+
recorder?.AppendVideo(sampleBuffer);
106+
return;
107+
}
108+
81109
// ⚠️ Borrow only when nothing is going to write to this buffer. The recorder composites
82110
// effects and the burn-in overlay back into it (AppleVideoOverlayRecorder.Composite), so
83111
// with one attached a borrowed frame would be read by the analyzer on one thread while
@@ -122,6 +150,40 @@ public override void DidOutputSampleBuffer(AVCaptureOutput captureOutput, CMSamp
122150
// Reused across frames so a steady-state render allocates nothing here.
123151
readonly List<CIImage> produced = [];
124152

153+
/// <summary>
154+
/// Runs the effect chain into the scratch buffer, for an analyzer rather than for the screen.
155+
/// </summary>
156+
/// <remarks>
157+
/// Separate from <see cref="RenderFiltered"/> because the destinations have nothing in common:
158+
/// that one produces an image for a view, this one fills a pixel buffer another consumer will
159+
/// read. The recipe is identical, and both render at the source extent for the reason spelled
160+
/// out there.
161+
/// </remarks>
162+
CVPixelBuffer? RenderForAnalyzer(CIFilter[] chain, CVPixelBuffer pixelBuffer)
163+
{
164+
using var input = new CIImage(pixelBuffer);
165+
166+
this.produced.Clear();
167+
var output = AppleCameraFilters.Apply(input, chain, this.produced);
168+
169+
try
170+
{
171+
return output is null ? null : this.filtered.Render(this.context, output, input.Extent);
172+
}
173+
catch (Exception)
174+
{
175+
// One frame without the effect on it beats taking the capture pipeline down.
176+
return null;
177+
}
178+
finally
179+
{
180+
foreach (var image in this.produced)
181+
image.Dispose();
182+
183+
this.produced.Clear();
184+
}
185+
}
186+
125187
void RenderFiltered(CIFilter[] chain, CVPixelBuffer pixelBuffer, UIImageView view)
126188
{
127189
using var input = new CIImage(pixelBuffer);

src/Shiny.Maui.Controls.Camera/Platforms/AppleShared/AppleCameraFrame.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ public static AppleCameraFrame Borrow(CMSampleBuffer sampleBuffer, CVPixelBuffer
119119
/// the recorder writing into the original — and it replaces a full-frame CPU copy rather than adding to
120120
/// one.
121121
/// </remarks>
122+
/// <summary>
123+
/// Wraps a buffer this library owns and will reuse, without copying it.
124+
/// </summary>
125+
/// <remarks>
126+
/// For the filtered frame handed to an analyzer - see <see cref="FilteredFrameBuffer"/>. There
127+
/// is no CMSampleBuffer behind it and nothing to release: the buffer belongs to the delegate
128+
/// that rendered it, and is not touched again until the analysis has returned.
129+
/// </remarks>
130+
internal static AppleCameraFrame Wrap(CVPixelBuffer pixelBuffer, int rotation, bool mirrored)
131+
=> new(owned: null, pixelBuffer, bgra: null, rotation, mirrored);
132+
122133
public static AppleCameraFrame Copy(CVPixelBuffer pixelBuffer, int rotation, bool mirrored)
123134
{
124135
if (!IsBiplanarFormat(pixelBuffer.PixelFormatType))
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using CoreGraphics;
2+
using CoreImage;
3+
using CoreVideo;
4+
5+
namespace Shiny.Maui.Controls.Camera;
6+
7+
/// <summary>
8+
/// A reusable surface for handing an analyzer the frame as the preview draws it.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// One buffer, reused for the life of the delegate and reallocated only when the frame size
13+
/// changes. That is safe because the pipeline analyses one frame at a time - <c>WantsFrame</c>
14+
/// refuses a frame while a pass is in flight - so the buffer handed out is never the one being
15+
/// rendered into.
16+
/// </para>
17+
/// <para>
18+
/// BGRA because that is what everything downstream of <see cref="AppleCameraFrame"/> expects, and
19+
/// IOSurface-backed so Core Image can render into it on the GPU rather than reading it back.
20+
/// </para>
21+
/// </remarks>
22+
sealed class FilteredFrameBuffer : IDisposable
23+
{
24+
CVPixelBuffer? buffer;
25+
nint width;
26+
nint height;
27+
28+
/// <summary>Draws a filtered image into the scratch buffer, or null if it could not be made.</summary>
29+
public CVPixelBuffer? Render(CIContext context, CIImage image, CGRect extent)
30+
{
31+
var w = (nint)extent.Width;
32+
var h = (nint)extent.Height;
33+
34+
if (w <= 0 || h <= 0)
35+
return null;
36+
37+
if (this.buffer is null || this.width != w || this.height != h)
38+
{
39+
this.buffer?.Dispose();
40+
this.buffer = new CVPixelBuffer(
41+
w,
42+
h,
43+
CVPixelFormatType.CV32BGRA,
44+
new CVPixelBufferAttributes { PixelFormatType = CVPixelFormatType.CV32BGRA }
45+
);
46+
47+
this.width = w;
48+
this.height = h;
49+
}
50+
51+
if (this.buffer is null)
52+
return null;
53+
54+
// Rendered at the source extent for the same reason the preview is - see RenderFiltered.
55+
context.Render(image, this.buffer, extent, null);
56+
return this.buffer;
57+
}
58+
59+
public void Dispose()
60+
{
61+
this.buffer?.Dispose();
62+
this.buffer = null;
63+
}
64+
}

0 commit comments

Comments
 (0)