Commit: 259e8d6
Parent: d430925

Camera feed

Mårten Åsberg committed on 2026-11-07 at 13:27
Directory.Packages.props +3 -0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index adfd080..75601ca 100644
@@ -9,5 +9,8 @@
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.5" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.5" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Emgu.CV" Version="5.0.0.6584" />
<PackageVersion Include="FlashCap" Version="1.12.0" />
<PackageVersion Include="SkiaImageView.Avalonia11" Version="1.5.0" />
</ItemGroup>
</Project>
app/src/BookScanner.App.csproj +2 -0
diff --git a/app/src/BookScanner.App.csproj b/app/src/BookScanner.App.csproj
index 4b294f0..c9cc274 100644
@@ -16,5 +16,7 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="FlashCap" />
<PackageReference Include="SkiaImageView.Avalonia11" />
</ItemGroup>
</Project>
app/src/Controls/CameraPageControl.axaml +26 -0
diff --git a/app/src/Controls/CameraPageControl.axaml b/app/src/Controls/CameraPageControl.axaml
new file mode 100644
index 0000000..bdfc84e
@@ -0,0 +1,26 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:services="using:BookScanner.App.Services"
xmlns:siv="using:SkiaImageView"
mc:Ignorable="d"
d:DesignWidth="400"
d:DesignHeight="450"
x:Class="BookScanner.App.Controls.CameraPageControl"
>
<Grid RowDefinitions="Auto,*">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Grid.Row="0">
<ComboBox Name="cameraSelector" SelectionChanged="OnCameraSelectionChange">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CameraInfo">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Click="OnRotateClick">Rotate</Button>
</StackPanel>
<siv:SKImageView Name="image" Grid.Row="1" Stretch="Uniform" StretchDirection="Both" />
</Grid>
</UserControl>
app/src/Controls/CameraPageControl.axaml.cs +98 -0
diff --git a/app/src/Controls/CameraPageControl.axaml.cs b/app/src/Controls/CameraPageControl.axaml.cs
new file mode 100644
index 0000000..bac1d2e
@@ -0,0 +1,98 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using BookScanner.App.Services;
using SkiaSharp;
namespace BookScanner.App.Controls;
public partial class CameraPageControl : UserControl
{
public static readonly DirectProperty<CameraPageControl, CameraPageSide> SideProperty =
AvaloniaProperty.RegisterDirect<CameraPageControl, CameraPageSide>(
nameof(Side),
o => o.Side,
(o, v) => o.Side = v
);
public CameraPageSide Side
{
get;
set
{
image.HorizontalAlignment =
value is CameraPageSide.Left
? Avalonia.Layout.HorizontalAlignment.Right
: Avalonia.Layout.HorizontalAlignment.Left;
SetAndRaise(SideProperty, ref field, value);
}
}
private int rotation = 0;
private SKBitmap? rotatedBitmap;
private CameraInfo? subscribedCamera;
public CameraPageControl()
{
InitializeComponent();
cameraSelector.ItemsSource = CameraService.Cameras;
}
private void OnCameraSelectionChange(object? sender, SelectionChangedEventArgs args)
{
if (subscribedCamera is not null)
{
CameraService.Unsubscribe(subscribedCamera, OnFrame);
}
if (cameraSelector.SelectedItem is not CameraInfo cameraInfo)
{
return;
}
subscribedCamera = cameraInfo;
CameraService.Subscribe(subscribedCamera, OnFrame);
}
private void OnFrame(SKBitmap bitmap)
{
rotatedBitmap?.Dispose();
rotatedBitmap = null;
if (rotation is not 0)
{
var (width, height) = rotation is 2 ? (bitmap.Width, bitmap.Height) : (bitmap.Height, bitmap.Width);
rotatedBitmap = new SKBitmap(width, height);
using var surface = new SKCanvas(rotatedBitmap);
surface.RotateDegrees(90 * rotation, width / 2, height / 2);
surface.DrawBitmap(bitmap, -(bitmap.Width - width) / 2, -(bitmap.Height - height) / 2);
bitmap = rotatedBitmap;
}
Dispatcher.Invoke(() =>
{
image.Source = bitmap;
});
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (subscribedCamera is not null)
{
CameraService.Unsubscribe(subscribedCamera, OnFrame);
}
base.OnDetachedFromVisualTree(e);
}
private void OnRotateClick(object? sender, RoutedEventArgs args)
{
rotation = (rotation + 1) % 4;
}
}
public enum CameraPageSide
{
Left,
Right,
}
app/src/MainWindow.axaml.cs +9 -0
diff --git a/app/src/MainWindow.axaml.cs b/app/src/MainWindow.axaml.cs
index 35aa186..ee4ab45 100644
@@ -1,4 +1,6 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using BookScanner.App.Services;
namespace BookScanner.App;
@@ -8,4 +10,11 @@ public partial class MainWindow : Window
{
InitializeComponent();
}
protected override void OnUnloaded(RoutedEventArgs e)
{
CameraService.Close();
base.OnUnloaded(e);
}
}
app/src/Pages/RecordingPage.axaml +5 -0
diff --git a/app/src/Pages/RecordingPage.axaml b/app/src/Pages/RecordingPage.axaml
index dfbf4ef..d1e4264 100644
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="using:BookScanner.App.Controls"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
@@ -11,4 +12,8 @@
<ContentPage.Header>
<TextBlock Name="projectName" />
</ContentPage.Header>
<Grid RowDefinitions="*,Auto" ColumnDefinitions="*,*">
<controls:CameraPageControl Name="leftCameraPage" Side="Left" Grid.Row="0" Grid.Column="0" />
<controls:CameraPageControl Name="rightCameraPage" Side="Right" Grid.Row="0" Grid.Column="1" />
</Grid>
</ContentPage>
app/src/Pages/StartPage.axaml.cs +1 -1
diff --git a/app/src/Pages/StartPage.axaml.cs b/app/src/Pages/StartPage.axaml.cs
index 1d7559f..d9d20f6 100644
@@ -16,7 +16,7 @@ public partial class StartPage : ContentPage
InitializeComponent();
}
private async void OnOpenProjectClick(object sender, RoutedEventArgs args)
private async void OnOpenProjectClick(object? sender, RoutedEventArgs args)
{
var topLevel = TopLevel.GetTopLevel(this)!;
var documentsFolder = await topLevel.StorageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
app/src/Services/CameraService.cs +105 -0
diff --git a/app/src/Services/CameraService.cs b/app/src/Services/CameraService.cs
new file mode 100644
index 0000000..1261ce0
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using FlashCap;
using SkiaSharp;
namespace BookScanner.App.Services;
public static class CameraService
{
public static IEnumerable<CameraInfo> Cameras
{
get
{
var devices = new CaptureDevices();
return devices.EnumerateDescriptors().Select(d => new CameraInfo(d, d.Name ?? d.Description));
}
}
private static Dictionary<object, DeviceManager> devices = [];
public static void Subscribe(CameraInfo cameraInfo, Action<SKBitmap> handler)
{
if (!devices.TryGetValue(cameraInfo.Descriptor.Identity, out var device))
{
devices[cameraInfo.Descriptor.Identity] = device = new(cameraInfo.Descriptor);
}
device.OnFrame += handler;
}
public static void Unsubscribe(CameraInfo cameraInfo, Action<SKBitmap> handler)
{
if (!devices.TryGetValue(cameraInfo.Descriptor.Identity, out var device))
{
return;
}
device.OnFrame -= handler;
}
public static void Close()
{
foreach (var device in devices.Values)
{
device.Dispose();
}
}
}
public sealed record CameraInfo(CaptureDeviceDescriptor Descriptor, string Label);
public sealed class DeviceManager(CaptureDeviceDescriptor descriptor) : IDisposable
{
private readonly Lock watchLock = new();
private CaptureDevice? captureDevice;
private SKBitmap? previousBitmap;
private Action<SKBitmap>? onFrame;
public event Action<SKBitmap> OnFrame
{
add
{
watchLock.Enter();
if (onFrame is null)
{
var characteristics = descriptor.Characteristics.MaxBy(c => c.Width * c.Height)!;
descriptor
.OpenAsync(characteristics, TranscodeFormats.Auto, OnPixelBufferArrived)
.ContinueWith(t =>
{
captureDevice = t.Result;
captureDevice.StartAsync();
watchLock.Exit();
});
}
onFrame += value;
}
remove
{
watchLock.Enter();
onFrame -= value;
if (onFrame is null)
{
captureDevice?.Dispose();
}
}
}
private void OnPixelBufferArrived(PixelBufferScope bufferScope)
{
var buffer = bufferScope.Buffer.ReferImage();
var bitmap = SKBitmap.Decode(buffer);
bufferScope.ReleaseNow();
onFrame?.Invoke(bitmap);
previousBitmap?.Dispose();
previousBitmap = bitmap;
}
public void Dispose()
{
previousBitmap?.Dispose();
captureDevice?.Dispose();
}
}
app/src/packages.lock.json +24 -0
diff --git a/app/src/packages.lock.json b/app/src/packages.lock.json
index 1e1449f..fa7f9e0 100644
@@ -54,6 +54,25 @@
"Microsoft.IO.RecyclableMemoryStream": "3.0.1"
}
},
"FlashCap": {
"type": "Direct",
"requested": "[1.12.0, )",
"resolved": "1.12.0",
"contentHash": "bT3D8eSvG4trCErOHXUX4BNAUMfV1Ghri8DINN0CIopjSyi0jgIxV1K0sZegO/gg3PK7xJ03CuY3tWACn0WAbA==",
"dependencies": {
"FlashCap.Core": "1.12.0"
}
},
"SkiaImageView.Avalonia11": {
"type": "Direct",
"requested": "[1.5.0, )",
"resolved": "1.5.0",
"contentHash": "RtphriyUHZG+AQiIJDud5lowe/kifcP0T/tRhpS5jzloZoDP8nO5DmphzY/WZri19OupF5sMilzWTjQgLSnDhA==",
"dependencies": {
"Avalonia": "11.0.0",
"SkiaSharp": "2.88.3"
}
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.27548.20260419",
@@ -139,6 +158,11 @@
"Avalonia.Skia": "12.1.5"
}
},
"FlashCap.Core": {
"type": "Transitive",
"resolved": "1.12.0",
"contentHash": "6LiaQSgB9lPa+pLSghbIM2FouS5N+vZC+bbLJ3xqRvzYwdJn4XjgEMrOhbYlNbnIpyQdEaL/pbeZyR4K6Y1hcg=="
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.3",