Hi Rekan Makers,
Alhamdulillah earthhour telah berakhir, tentunya rekan-rekan bisa beraktivitas dengan lancar kembali. Baiklah kali ini kita akan mencoba membuat contoh aplikasi sederhana yang memanfaatkan WinML untuk menjaga keamanan rumah. Jika rekan-rekan belum tahu apa itu WinML silakan baca artikel terdahulu disini. Tujuan dari artikel ini adalah rekan-rekan bisa memahami bagaimana menggunakan model deep learning dengan format onnx pada aplikasi UWP dengan bantuan WinML.
Beberapa hal yang rekan-rekan perlu siapkan adalah:
- PC atau Mini PC dengan listrik yang rendah
- Speaker
- Install PC dengan Windows 10 dengan build version >= 1809
- Install PC development dengan Win 10 SDK dengan versi >= Build 17763
- WebCam atau CCTV (perlu sedikit modifikasi code)
- Visual Studio 2017 – 2019 dengan extension ML Code Generator
- Knowledge tentang C# dan UWP
Idenya adalah kita punya aplikasi yang dapat mendeteksi objek dari streaming web cam atau CCTV. Objek yang jadi perhatian adalah manusia. Aplikasi ini memiliki mode jaga dan waktu jaga, sama seperti siskamling hanya bedanya ini tidak kenal lelah cukup bayar listrik aja. Ketika terdeteksi ada objek maka akan mengeluarkan suara alarm.
Baiklah mari kita mulai membuat, silakan ikut langkah berikut:
Bukalah Visual Studio, lalu buat project baru dengan template Blank App (Universal Windows)
Beri nama project, contoh: SecurityCam, lalu klik Create
Unduh model TinyYolov2 dari sini
Kemudian klik kanan pada folder Assets dan pilih add > New Item, lalu pilih file model yang sudah di unduh
Otomatis class dengan nama “tiny-yolov2-1.2.cs” akan digenerate, visual studio membuatkan class ini untuk memudahkan kita untuk meload model, dan melakukan inferensi. Buka file tersebut dan edit pada class Input, tulisan TensorFloat diganti dengan ImageFeatureValue seperti gambar dibawah ini karena kita akan memasukan input berupa gambar, dan sudah ada built-in function untuk mengubah bitmap menjadi ImageFeatureValue.
Lalu tambahkan class baru dengan nama “YoloBoundingBox.cs” isi kode berikut:
using System.Drawing;
namespace SecurityCam
{
class YoloBoundingBox
{
public string Label { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Height { get; set; }
public float Width { get; set; }
public float Confidence { get; set; }
public RectangleF Rect
{
get { return new RectangleF(X, Y, Width, Height); }
}
}
}
Class diatas digunakan untuk menyimpan lokasi berbentuk kotak, untuk menandai objek yang terdeteksi. Install nuget package dengan cara, klik kanan pada project > Manage Nuget Packages. Search package dengan nama “System.Drawing.Primitives” lalu install, tambahkan juga “Microsoft.Toolkit.Uwp.UI.Controls” lalu install. 2 package ini dibutuhkan untuk control akses ke webcam dan ada class yang dibutuhkan boundingbox.
Selanjutnya buat class helper dengan nama “YoloWinMlParser.cs” isi kode berikut:
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace SecurityCam
{
class YoloWinMlParser
{
public const int ROW_COUNT = 13;
public const int COL_COUNT = 13;
public const int CHANNEL_COUNT = 125;
public const int BOXES_PER_CELL = 5;
public const int BOX_INFO_FEATURE_COUNT = 5;
public const int CLASS_COUNT = 20;
public const float CELL_WIDTH = 32;
public const float CELL_HEIGHT = 32;
private int channelStride = ROW_COUNT * COL_COUNT;
private float[] anchors = new float[]{
1.08F, 1.19F, 3.42F, 4.41F, 6.63F, 11.38F, 9.42F, 5.11F, 16.62F, 10.52F
};
private string[] labels = new string[]{
"aeroplane", "bicycle", "bird", "boat", "bottle",
"bus", "car", "cat", "chair", "cow",
"diningtable", "dog", "horse", "motorbike", "person",
"pottedplant", "sheep", "sofa", "train", "tvmonitor"
};
public IList<YoloBoundingBox> ParseOutputs(float[] yoloModelOutputs, float threshold = .3F)
{
var boxes = new List<YoloBoundingBox>();
var featuresPerBox = BOX_INFO_FEATURE_COUNT + CLASS_COUNT;
var stride = featuresPerBox * BOXES_PER_CELL;
for (int cy = 0; cy < ROW_COUNT; cy++)
{
for (int cx = 0; cx < COL_COUNT; cx++)
{
for (int b = 0; b < BOXES_PER_CELL; b++)
{
var channel = (b * (CLASS_COUNT + BOX_INFO_FEATURE_COUNT));
var tx = yoloModelOutputs[GetOffset(cx, cy, channel)];
var ty = yoloModelOutputs[GetOffset(cx, cy, channel + 1)];
var tw = yoloModelOutputs[GetOffset(cx, cy, channel + 2)];
var th = yoloModelOutputs[GetOffset(cx, cy, channel + 3)];
var tc = yoloModelOutputs[GetOffset(cx, cy, channel + 4)];
var x = ((float)cx + Sigmoid(tx)) * CELL_WIDTH;
var y = ((float)cy + Sigmoid(ty)) * CELL_HEIGHT;
var width = (float)Math.Exp(tw) * CELL_WIDTH * this.anchors[b * 2];
var height = (float)Math.Exp(th) * CELL_HEIGHT * this.anchors[b * 2 + 1];
var confidence = Sigmoid(tc);
if (confidence < threshold)
continue;
var classes = new float[CLASS_COUNT];
var classOffset = channel + BOX_INFO_FEATURE_COUNT;
for (int i = 0; i < CLASS_COUNT; i++)
classes[i] = yoloModelOutputs[GetOffset(cx, cy, i + classOffset)];
var results = Softmax(classes)
.Select((v, ik) => new { Value = v, Index = ik });
var topClass = results.OrderByDescending(r => r.Value).First().Index;
var topScore = results.OrderByDescending(r => r.Value).First().Value * confidence;
var testSum = results.Sum(r => r.Value);
if (topScore < threshold)
continue;
boxes.Add(new YoloBoundingBox()
{
Confidence = topScore,
X = (x - width / 2),
Y = (y - height / 2),
Width = width,
Height = height,
Label = this.labels[topClass]});
}
}
}
return boxes;
}
public IList<YoloBoundingBox> NonMaxSuppress(IList<YoloBoundingBox> boxes, int limit, float threshold)
{
var activeCount = boxes.Count;
var isActiveBoxes = new bool[boxes.Count];
for (int i = 0; i < isActiveBoxes.Length; i++)
isActiveBoxes[i] = true;
var sortedBoxes = boxes.Select((b, i) => new { Box = b, Index = i })
.OrderByDescending(b => b.Box.Confidence)
.ToList();
var results = new List<YoloBoundingBox>();
for (int i = 0; i < boxes.Count; i++)
{
if (isActiveBoxes[i])
{
var boxA = sortedBoxes[i].Box;
results.Add(boxA);
if (results.Count >= limit)
break;
for (var j = i + 1; j < boxes.Count; j++)
{
if (isActiveBoxes[j])
{
var boxB = sortedBoxes[j].Box;
if (IntersectionOverUnion(boxA.Rect, boxB.Rect) > threshold)
{
isActiveBoxes[j] = false;
activeCount--;
if (activeCount <= 0)
break;
}
}
}
if (activeCount <= 0)
break;
}
}
return results;
}
private float IntersectionOverUnion(RectangleF a, RectangleF b)
{
var areaA = a.Width * a.Height;
if (areaA <= 0)
return 0;
var areaB = b.Width * b.Height;
if (areaB <= 0)
return 0;
var minX = Math.Max(a.Left, b.Left);
var minY = Math.Max(a.Top, b.Top);
var maxX = Math.Min(a.Right, b.Right);
var maxY = Math.Min(a.Bottom, b.Bottom);
var intersectionArea = Math.Max(maxY - minY, 0) * Math.Max(maxX - minX, 0);
return intersectionArea / (areaA + areaB - intersectionArea);
}
private int GetOffset(int x, int y, int channel)
{
// YOLO outputs a tensor that has a shape of 125x13x13, which
// WinML flattens into a 1D array. To access a specific channel
// for a given (x,y) cell position, we need to calculate an offset
// into the array
return (channel * this.channelStride) + (y * COL_COUNT) + x;
}
private float Sigmoid(float value)
{
var k = (float)Math.Exp(value);
return k / (1.0f + k);
}
private float[] Softmax(float[] values)
{
var maxVal = values.Max();
var exp = values.Select(v => Math.Exp(v - maxVal));
var sumExp = exp.Sum();
return exp.Select(v => (float)(v / sumExp)).ToArray();
}
}
}
Ini adalah class yang memiliki method-method yang kita butuhkan untuk memproses output. Ada label yang mengintepretasikan objek yang dideteksi. Method ParseOutputs digunakan untuk mengubah output model menjadi bounding box berisi objek yang terdeteksi. Daftar objek ini akan di filter berdasarkan confidence level yang lebih besar dari limit threshold yang kita tentukan. Versi tiny yolo ini hanya mengenali 20 class (objek), dan mengeluarkan 5 fitur tambahan berisi lokasi (x,y), ukuran object (w,h), confidence level. Kemudian ada method NonMaxSurpression untuk mengurangi jumlah bounding box yang akan ditampilkan berdasarkan nilai IntersectionOverUnion, seberapa besar intersection antara bounding box hasil prediksi dengan bounding box sebenarnya (hasil labeling manual saat training). Semakin besar semakin baik, dan kita batasi dengan threshold dan batas jumlah bounding box yang mau ditampilkan di screen.
Sedikit informasi mengenai cara kerja model tiny yolo ini, gambar yang di input akan diresize menjadi ukuran 416×416 pixel. Lalu gambar ini akan dibagi rata menjadi 13×13 cell. jadi setiap cell punya ukuran 416 / 13 = 32px. Setiap cell ini akan diproses model bisa memprediksi 5 bounding box. Jadi outputnya berupa 25 (20 class feature + 5 box feature) x 5 (box per cell) x 13 x 13 (jumlah cell per-gambar).
Buatlah class “SpeechHelper.cs” untuk mengubah teks menjadi suara dengan fitur Speech Syntesis windows 10. Masukan kode berikut:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Windows.Media.SpeechSynthesis;
using Windows.UI.Xaml.Controls;
namespace SecurityCam
{
/// <summary>
/// Utilizes SpeechSynthesizer to convert text to an audio message played through a XAML MediaElement
/// </summary>
public class SpeechHelper : IDisposable
{
private MediaElement mediaElement;
private SpeechSynthesizer synthesizer;
/// <summary>
/// Accepts a MediaElement that should be placed on whichever page user is on when text is read by SpeechHelper.
/// Initializes SpeechSynthesizer.
/// </summary>
public SpeechHelper(MediaElement media)
{
mediaElement = media;
synthesizer = new SpeechSynthesizer();
}
/// <summary>
/// Synthesizes passed through text as audio and plays speech through the MediaElement first sent through.
/// </summary>
public async Task Read(string text)
{
if (mediaElement != null && synthesizer != null)
{
var stream = await synthesizer.SynthesizeTextToStreamAsync(text);
mediaElement.AutoPlay = true;
mediaElement.SetSource(stream, stream.ContentType);
mediaElement.Play();
}
}
/// <summary>
/// Disposes of IDisposable type SpeechSynthesizer
/// </summary>
public void Dispose()
{
synthesizer.Dispose();
}
}
}
Sekarang buka MainPage.xaml, lalu masukan kode berikut:
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<my:CameraPreview x:Name="CameraPreview" Grid.Row="0" />
<Canvas Name="YoloCanvas" Grid.Row="0" />
<TextBlock x:Name="TextBlockInformation" Grid.Row="1" />
<my:Expander Grid.Row="2" IsExpanded="False" Header="Security Options" >
<StackPanel Orientation="Vertical">
<CheckBox x:Name="ChkGuardian" Content="Guardian Mode" />
<TimePicker x:Name="TimeStartPicker" Header="StartTime" SelectedTime="20:00" />
<TimePicker x:Name="TimeStopPicker" Header="StopTime" SelectedTime="05:00"></TimePicker>
<MediaElement Loaded="speechMediaElement_Loaded" Visibility="Collapsed" Name="speechMediaElement"></MediaElement>
</StackPanel>
</my:Expander>
</Grid>
Ini adalah tampilan sederhana kita, baris pertama berisi canvas untuk menampilkan streaming web cam dan bounding box, lalu informasi mengenai kecepatan running model ML, dan baris ketiga adalah pengaturan mode jaga, dan waktu jaga. perhatikan kontrol expander dan camerapreview itu dari Microsoft.Toolkit.Uwp.UI.Controls, jadi tambahkan baris ini di bagian atas: my=”using:Microsoft.Toolkit.Uwp.UI.Controls”
Lalu buka “MainPage.xaml.cs” masukan kode berikut:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.AI.MachineLearning;
using Windows.Graphics.Imaging;
using Windows.Media;
using Windows.Storage;
using Windows.UI.Core;
using Windows.UI.Text;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;
// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=402352&clcid=0x409
namespace SecurityCam
{
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class MainPage : Page
{
private const string _kModelFileName = "tiny-yolov2-1.2.onnx";
private Model _model = null;
private Input _input = new Input();
private Output _output = new Output();
private uint _canvasActualWidth;
private uint _canvasActualHeight;
private Stopwatch _stopwatch;
private readonly SolidColorBrush _lineBrushYellow = new SolidColorBrush(Windows.UI.Colors.Yellow);
private readonly SolidColorBrush _lineBrushGreen = new SolidColorBrush(Windows.UI.Colors.Green);
private readonly SolidColorBrush _fillBrush = new SolidColorBrush(Windows.UI.Colors.Transparent);
private readonly double _lineThickness = 2.0;
private IList<YoloBoundingBox> _boxes = new List<YoloBoundingBox>();
private readonly YoloWinMlParser _parser = new YoloWinMlParser();
static SpeechHelper speech;
bool IsPlaying = false;
static int personCount = 0;
DateTime LastDetect = DateTime.MinValue;
static int DetectionIntervalInSeconds = 3;
#region sound
async void PlaySound(string SoundFile)
{
if (IsPlaying) return;
await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, async () =>
{
IsPlaying = true;
MediaElement mysong = speechMediaElement;
Windows.Storage.StorageFolder folder = await Windows.ApplicationModel.Package.Current.InstalledLocation.GetFolderAsync("Assets");
Windows.Storage.StorageFile file = await folder.GetFileAsync(SoundFile);
var stream = await file.OpenAsync(Windows.Storage.FileAccessMode.Read);
mysong.SetSource(stream, file.ContentType);
mysong.Play();
//UI code here
});
}
private void speechMediaElement_MediaEnded(object sender, RoutedEventArgs e)
{
IsPlaying = false;
}
/// <summary>
/// Triggered when media element used to play synthesized speech messages is loaded.
/// Initializes SpeechHelper and greets user.
/// </summary>
private void speechMediaElement_Loaded(object sender, RoutedEventArgs e)
{
if (speech == null)
{
speech = new SpeechHelper(speechMediaElement);
speechMediaElement.MediaEnded += speechMediaElement_MediaEnded;
}
else
{
// Prevents media element from re-greeting visitor
speechMediaElement.AutoPlay = false;
}
}
#endregion
public MainPage()
{
this.InitializeComponent();
}
protected override async void OnNavigatedTo(NavigationEventArgs e)
{
// Load the model
await LoadModelAsync();
GetCameraSize();
Window.Current.SizeChanged += Current_SizeChanged;
await CameraPreview.StartAsync();
CameraPreview.CameraHelper.FrameArrived += CameraHelper_FrameArrived;
}
private void Current_SizeChanged(object sender, WindowSizeChangedEventArgs e)
{
GetCameraSize();
}
private void GetCameraSize()
{
_canvasActualWidth = (uint)CameraPreview.ActualWidth;
_canvasActualHeight = (uint)CameraPreview.ActualHeight;
}
private async Task LoadModelAsync()
{
// just load the model one time.
if (_model != null) return;
Debug.WriteLine($"Loading {_kModelFileName} ... patience ");
try
{
// Load and create the model
var modelFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri($"ms-appx:///Assets/{_kModelFileName}"));
_model = await Model.CreateFromStreamAsync(modelFile); //new LearningModelDevice(_kModelDeviceKind)
}
catch (Exception ex)
{
Debug.WriteLine($"error: {ex.Message}");
_model = null;
}
}
private async void CameraHelper_FrameArrived(object sender, Microsoft.Toolkit.Uwp.Helpers.FrameEventArgs e)
{
if (e?.VideoFrame?.SoftwareBitmap == null) return;
SoftwareBitmap softwareBitmap = SoftwareBitmap.Convert(e.VideoFrame.SoftwareBitmap, BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);
VideoFrame inputFrame = VideoFrame.CreateWithSoftwareBitmap(softwareBitmap);
_input.image = ImageFeatureValue.CreateFromVideoFrame(inputFrame);
// Evaluate the model
_stopwatch = Stopwatch.StartNew();
_output = await _model.EvaluateAsync(_input);
_stopwatch.Stop();
IReadOnlyList<float> VectorImage = _output.grid.GetAsVectorView();
float[] ImageAry = VectorImage.ToArray();
_boxes = _parser.ParseOutputs(ImageAry);
await Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () =>
{
TextBlockInformation.Text = $"{1000f / _stopwatch.ElapsedMilliseconds,4:f1} fps on Width {_canvasActualWidth} x Height {_canvasActualHeight}";
DrawOverlays(e.VideoFrame);
var Distance = DateTime.Now - LastDetect;
if (personCount > 0 && Distance.TotalSeconds > DetectionIntervalInSeconds && DateTime.Now.TimeOfDay >= TimeStartPicker.SelectedTime && DateTime.Now.TimeOfDay <= TimeStopPicker.SelectedTime)
{
if ((bool)ChkGuardian.IsChecked)
PlaySound("alarm.wav");
else
await speech.Read($"I saw {personCount} person in the camera");
LastDetect = DateTime.Now;
}
});
//Debug.WriteLine(ImageList.ToString());
}
private void DrawOverlays(VideoFrame inputImage)
{
YoloCanvas.Children.Clear();
if (_boxes.Count <= 0) return;
personCount = 0;
var filteredBoxes = _parser.NonMaxSuppress(_boxes, 5, .5F);
foreach (var box in filteredBoxes)
DrawYoloBoundingBox(box, YoloCanvas);
}
private void DrawYoloBoundingBox(YoloBoundingBox box, Canvas overlayCanvas)
{
// process output boxes
var x = (uint)Math.Max(box.X, 0);
var y = (uint)Math.Max(box.Y, 0);
var w = (uint)Math.Min(overlayCanvas.ActualWidth - x, box.Width);
var h = (uint)Math.Min(overlayCanvas.ActualHeight - y, box.Height);
// fit to current canvas and webcam size
x = _canvasActualWidth * x / 416;
y = _canvasActualHeight * y / 416;
w = _canvasActualWidth * w / 416;
h = _canvasActualHeight * h / 416;
var rectStroke = _lineBrushYellow;
if (box.Label == "person")
{
rectStroke = _lineBrushGreen;
personCount++;
}
var r = new Windows.UI.Xaml.Shapes.Rectangle
{
Tag = box,
Width = w,
Height = h,
Fill = _fillBrush,
Stroke = rectStroke,
StrokeThickness = _lineThickness,
Margin = new Thickness(x, y, 0, 0)
};
var tb = new TextBlock
{
Margin = new Thickness(x + 4, y + 4, 0, 0),
Text = $"{box.Label} ({Math.Round(box.Confidence, 4)})",
FontWeight = FontWeights.Bold,
Width = 126,
Height = 21,
HorizontalTextAlignment = TextAlignment.Center
};
var textBack = new Windows.UI.Xaml.Shapes.Rectangle
{
Width = 134,
Height = 29,
Fill = rectStroke,
Margin = new Thickness(x, y, 0, 0)
};
overlayCanvas.Children.Add(textBack);
overlayCanvas.Children.Add(tb);
overlayCanvas.Children.Add(r);
}
}
}
Nah berikut adalah penjelasan kode diatas:
- Pada saat aplikasi dijalankan method OnNavigatedTo akan dieksekusi, model akan diload dengan method LoadModelAsync. File model yang tersimpan pada Asset akan dimuat dalam objek StorageFile, lalu kita masukan ke dalam objek model dengan method CreateFromStreamAsync, method ini berasal dari code generator saat kita memasukan model onnx ke solution.
- Setiap ukuran window di resize kita perlu simpan size terakhir, untuk proses mapping output bounding box model dengan ukuran screen yang digunakan aplikasi. Lihat method GetCamera Size.
- Setiap component CameraPreview mendapatkan frame baru dari webcam, kita bisa memproses gambar frame tersebut sebagai input model. Kita perlu mengubah formatnya dengan format yang disupport UWP dengan method SoftwareBitmap.Convert, lalu membuat objek VideoFrame dengan fungsi VideoFrame.CreateWithSoftwareBitmap, dan extract pixel dari gambar dengan fungsi ImageFeatureValue.CreateFromVideoFrame.
- Lalu kita proses dengan input gambar dengan model dengan method _model.EvaluateAsync.
- Outputnya berupa TensorFloat (array 1 D) lalu kita filter dengan method ParseOutputs, lalu bounding box yang sudah terfilter kita gambar pada canvas.
- Kita hitung jumlah class = person yang terdeteksi
- jika dalam mode jaga dan kita temukan orang pada frame lalu kita bunyikan alarm, jika tidak maka hitung jumlah orang lalu ucapkan melalui fungsi Read dengan SpeechSyntesis. Kita beri jeda waktu biar tidak terlalu menumpuk suara alarmnya.
Jangan lupa masukan file “alarm.wav” ke dalam folder Assets berisi suara peringatan jika ada orang yang terdeteksi.
Jika ingin membaca source code lengkapnya bisa diunduh dari sini.
Semoga artikel ini bisa bermanfaat, dan projectnya bisa rekan-rekan realisasikan dalam kehidupan sehari-hari. Silakan tambah fiturnya dan bikin lebih canggih lagi. Jangan lupa ubah power settings menjadi never sleep, dan layar tidak terkunci otomatis.
Salam Makers ;D