Processamento de Ultrassom em Tempo Real com Alta Vazão no .NET 8

Este artigo descreve uma arquitetura para simular a aquisição de dados de ultrassom com alta taxa (10 MB/s) em um sistema supervisório (SCADA) baseado em Windows, onde a velocidade de processamento é muito menor que a de coleta. A solução cobre desde a captura, armazenamento, aálise espectral, geração de imagens até a análise visual, utliizando ConcurrentQueue + snapshots periódicos, com foco em desempenho e confiabilidade no .NET 8.

Contexto do Problema

  • Características do ultrassom: geração de ~1000 quadros/s, cada quadro com ~10 kB de forma de onda (amostras em double), totalizando ~10 MB/s.
  • Disparidade de velocidades: captura (10 MB/s) >> processamento (1 MB/s). Atraso aceitável para exibição é de segundos.
  • Requisitos: persistência em SQLite, imagem em tempo real, recuperação após falha, baixo uso de memória.
  • Plataforma: Windows 10/11, .NET 8 WinForms, 4 cores, 16 GB RAM, SSD.

Arquitetura Proposta

Baseada em fila concorrente + snapshot periódico, com as seguintes responsabilidades:

  1. Coleta assíncrona → enfileira quadros em ConcurrentQueue<QuadroUltrassom> (~microssegundos).
  2. Persistência em lote → SQLite modo WAL, transações de 1000 quadros (~50 ms).
  3. Análise → tarefa separada consome da fila (1 MB/s) e executa transformada de Fourier simplificada.
  4. Geração de imagem → SkiaSharp converte espectro em bitmap em tons de cinza.
  5. Análise de imagem → detecção de bordas simulada.
  6. Sanpshot → a cada 10 segundos serializa a fila com MessagePack (arquivo .snap).

Implementação (.NET 8, WinForms)

using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Data.SQLite;
using System.Drawing;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using MessagePack;
using SkiaSharp;

namespace UltraSim
{
    public partial class MainForm : Form
    {
        private CancellationTokenSource _cts;
        private ConcurrentQueue<FrameData> _queue = new();
        private const int BatchSize = 1000;
        private const int SnapshotIntervalMs = 10_000;
        private const int MaxQueueItems = 50_000;
        private static readonly string DbPath = "ultrasound.db";
        private static readonly string SnapPath = "snapshot.bin";
        private SQLiteConnection _db;
        private PictureBox _imgBox;

        [MessagePackObject]
        public struct FrameData
        {
            [Key(0)] public string Timestamp;
            [Key(1)] public double[] Signal; // 2560 doubles ≈ 10 KB

            public FrameData(DateTime when, Random rng)
            {
                Timestamp = when.ToString("o");
                Signal = ArrayPool<double>.Shared.Rent(2560);
                for (int i = 0; i < 2560; i++)
                    Signal[i] = Math.Sin(i * 0.01) + rng.NextDouble() * 0.1;
            }

            public void Release() => ArrayPool<double>.Shared.Return(Signal);
        }

        public MainForm()
        {
            InitializeComponent();
            BuildUI();
            OpenDb();
        }

        private void BuildUI()
        {
            Text = "Ultrassom Simulator - .NET 8";
            ClientSize = new Size(800, 600);
            var btnStart = new Button { Text = "Iniciar", Location = new Point(20, 20), Width = 100 };
            btnStart.Click += (_, _) => Start();
            var btnStop = new Button { Text = "Parar", Location = new Point(130, 20), Width = 100 };
            btnStop.Click += (_, _) => Stop();
            var log = new TextBox
            {
                Location = new Point(20, 60), Width = 760, Height = 200,
                Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical
            };
            _imgBox = new PictureBox
            {
                Location = new Point(20, 270), Width = 760, Height = 300,
                BorderStyle = BorderStyle.FixedSingle
            };
            Controls.AddRange(new Control[] { btnStart, btnStop, log, _imgBox });
        }

        private void OpenDb()
        {
            _db = new SQLiteConnection($"Data Source={DbPath};Pooling=True;");
            _db.Open();
            using var cmd = _db.CreateCommand();
            cmd.CommandText = @"
                PRAGMA journal_mode=WAL;
                PRAGMA synchronous=NORMAL;
                PRAGMA cache_size=10000;
                CREATE TABLE IF NOT EXISTS Frames (Timestamp TEXT, Waveform BLOB)";
            cmd.ExecuteNonQuery();
        }

        private async void Start()
        {
            if (_cts != null) return;
            _cts = new CancellationTokenSource();
            var token = _cts.Token;

            await RestoreSnapshotAsync(token);

            _ = Task.Run(() => Acquire(token));
            _ = Task.Run(() => Process(token));
            _ = Task.Run(() => SnapshotLoop(token));
        }

        private void Stop()
        {
            _cts?.Cancel();
            _cts?.Dispose();
            _cts = null;
            // Aguarda descarga dos dados acumulados (simplificado)
            Task.Run(async () =>
            {
                await FlushAsync();
                _db?.Close();
            }).Wait();
        }

        private async Task Acquire(CancellationToken ct)
        {
            var rng = new Random();
            int counter = 0;
            while (!ct.IsCancellationRequested)
            {
                if (_queue.Count >= MaxQueueItems)
                {
                    await Task.Delay(10, ct);
                    continue;
                }

                var frame = new FrameData(DateTime.UtcNow, rng);
                _queue.Enqueue(frame);
                counter++;

                if (counter % 200 == 0)
                {
                    BeginInvoke(() =>
                    {
                        var log = Controls[2] as TextBox;
                        log.AppendText($"Quadro {counter} coletado. Fila: {_queue.Count}\n");
                    });
                }

                if (_queue.Count >= BatchSize)
                    await FlushAsync();

                await Task.Delay(1, ct); // ~1000 quadros/s
            }
        }

        private async Task FlushAsync()
        {
            var batch = new List<FrameData>(BatchSize);
            while (_queue.TryDequeue(out var f) && batch.Count < BatchSize)
                batch.Add(f);

            if (batch.Count == 0) return;

            try
            {
                using var tx = _db.BeginTransaction();
                using var cmd = _db.CreateCommand();
                cmd.CommandText = "INSERT INTO Frames (Timestamp, Waveform) VALUES (@ts, @wf)";
                var pTs = cmd.Parameters.Add("@ts", System.Data.DbType.String);
                var pWf = cmd.Parameters.Add("@wf", System.Data.DbType.Binary);

                foreach (var f in batch)
                {
                    pTs.Value = f.Timestamp;
                    pWf.Value = MessagePackSerializer.Serialize(f.Signal);
                    cmd.ExecuteNonQuery();
                    f.Release();
                }
                tx.Commit();
            }
            catch (Exception ex)
            {
                BeginInvoke(() =>
                {
                    var log = Controls[2] as TextBox;
                    log.AppendText($"Erro ao inserir lote: {ex.Message}\n");
                });
            }
        }

        private async Task Process(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                if (_queue.TryDequeue(out var f))
                {
                    var espectro = Analisar(f.Signal);
                    using var bmp = GerarImagem(espectro);
                    var analise = AnalisarImagem(bmp);

                    BeginInvoke(() =>
                    {
                        _imgBox.Image?.Dispose();
                        _imgBox.Image = bmp.ToBitmap();
                        var log = Controls[2] as TextBox;
                        log.AppendText($"Processado {f.Timestamp}: {analise}\n");
                    });

                    f.Release();
                }
                await Task.Delay(10, ct); // ~100 quadros/s
            }
        }

        private double[] Analisar(double[] sinal)
        {
            var r = new double[sinal.Length];
            for (int i = 0; i < sinal.Length; i++)
                r[i] = sinal[i] * 0.5; // placeholder
            return r;
        }

        private SKBitmap GerarImagem(double[] dados)
        {
            var bmp = new SKBitmap(256, 256);
            using var canvas = new SKCanvas(bmp);
            canvas.Clear(SKColors.Black);
            for (int x = 0; x < 256 && x < dados.Length; x++)
            {
                int val = (int)(Math.Abs(dados[x]) * 255);
                val = Math.Clamp(val, 0, 255);
                var cor = new SKColor((byte)val, (byte)val, (byte)val);
                for (int y = 0; y < 256; y++)
                    bmp.SetPixel(x, y, cor);
            }
            return bmp;
        }

        private string AnalisarImagem(SKBitmap bmp) => "Bordas detectadas (simulação)";

        private async Task SnapshotLoop(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                await Task.Delay(SnapshotIntervalMs, ct);
                if (_queue.Count == 0) continue;

                var itens = new List<FrameData>();
                while (_queue.TryDequeue(out var f))
                    itens.Add(f);

                try
                {
                    using var fs = new FileStream(SnapPath, FileMode.Create, FileAccess.Write,
                                                  FileShare.None, 8192, true);
                    await MessagePackSerializer.SerializeAsync(fs, itens, cancellationToken: ct);

                    // Reenfileira para não perder dados em uso
                    foreach (var f in itens)
                        _queue.Enqueue(f);

                    BeginInvoke(() =>
                    {
                        var log = Controls[2] as TextBox;
                        log.AppendText($"Snapshot salvo: {itens.Count} quadros\n");
                    });
                }
                catch (Exception ex)
                {
                    BeginInvoke(() =>
                    {
                        var log = Controls[2] as TextBox;
                        log.AppendText($"Falha no snapshot: {ex.Message}\n");
                    });
                }
            }
        }

        private async Task RestoreSnapshotAsync(CancellationToken ct)
        {
            if (!File.Exists(SnapPath)) return;
            try
            {
                using var fs = new FileStream(SnapPath, FileMode.Open, FileAccess.Read, FileShare.Read);
                var lista = await MessagePackSerializer.DeserializeAsync<List<FrameData>>(fs, cancellationToken: ct);
                foreach (var f in lista)
                    _queue.Enqueue(f);
                File.Delete(SnapPath);
                BeginInvoke(() =>
                {
                    var log = Controls[2] as TextBox;
                    log.AppendText($"Restaurados {lista.Count} quadros do snapshot\n");
                });
            }
            catch (Exception ex)
            {
                BeginInvoke(() =>
                {
                    var log = Controls[2] as TextBox;
                    log.AppendText($"Restauração falhou: {ex.Message}\n");
                });
            }
        }

        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new MainForm());
        }
    }
}

Configuração do Projeto

dotnet new winforms -n UltraSim
cd UltraSim
dotnet add package System.Data.SQLite.Core --version 1.0.118.0
dotnet add package MessagePack --version 2.5.168
dotnet add package SkiaSharp --version 2.88.8

Arquivo .csproj (relevante):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Data.SQLite.Core" Version="1.0.118.0" />
    <PackageReference Include="MessagePack" Version="2.5.168" />
    <PackageReference Include="SkiaSharp" Version="2.88.8" />
  </ItemGroup>
</Project>

Resultados Esperados

  • Vazão: coleta de 10 MB/s (1000 quadros/s), processamento de 1 MB/s (100 quadros/s).
  • Persistência: cada lote de 1000 quadros é inserido no SQLite em ~50 ms.
  • Snapshot: a cada 10 s, a fila (até 100 MB) é serializada em ~20 ms.
  • Recuperação: até 10 s de dados perdidos máxima (100 MB).
  • Imagem: atualização a cada ~1 s, sem congelamento da UI.

Este projeto pode ser adaptado para taxas mais altas (aumentando o batch ou usando buffers de arquivo).

Tags: ConcurrentQueue C# sqlite WAL SkiaSharp

Publicado em 6-23 01:51