Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4-preview.1.1" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />

<!-- Phase 15: local AI analytics. ONNX Runtime in-process (no Python
sidecar). Base package ships the CPU EP on every RID — the mandatory
fallback. GPU/NPU EPs (DirectML/CUDA/CoreML/NNAPI) are added per-head
via conditional PackageReference; the detector degrades to CPU when an
EP is unavailable, so the app never crashes because of acceleration. -->
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />

<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
Expand Down
6 changes: 5 additions & 1 deletion OpenIPC.Viewer.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Project Path="src/OpenIPC.Viewer.Android/OpenIPC.Viewer.Android.csproj">
<Deploy Solution="Debug|*" />
</Project>
<Project Path="src/OpenIPC.Viewer.Analytics/OpenIPC.Viewer.Analytics.csproj" />
<Project Path="src/OpenIPC.Viewer.App/OpenIPC.Viewer.App.csproj" />
<Project Path="src/OpenIPC.Viewer.Composition/OpenIPC.Viewer.Composition.csproj" />
<Project Path="src/OpenIPC.Viewer.Core/OpenIPC.Viewer.Core.csproj" />
Expand All @@ -13,8 +14,11 @@
<Project Path="src/OpenIPC.Viewer.Video/OpenIPC.Viewer.Video.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenIPC.Viewer.Analytics.Tests/OpenIPC.Viewer.Analytics.Tests.csproj" />
<Project Path="tests/OpenIPC.Viewer.Core.Tests/OpenIPC.Viewer.Core.Tests.csproj" />
<Project Path="tests/OpenIPC.Viewer.Infrastructure.Tests/OpenIPC.Viewer.Infrastructure.Tests.csproj" />
<Project Path="tests/OpenIPC.Viewer.Infrastructure.Tests/OpenIPC.Viewer.Infrastructure.Tests.csproj">
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="tests/OpenIPC.Viewer.Video.Tests/OpenIPC.Viewer.Video.Tests.csproj" />
</Folder>
</Solution>
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ and scope live in the planning docs (`dashboard-ideas-roadmap-ru.md`).
| 12 | Streaming hardening | Smart-pause hidden tiles, auto SD/HD, watchdog + backoff, last-frame hold, error tile | ✅ Done |
| 13 | SSH device suite | SSH terminal, SCP file manager, open-in-browser, config push | ✅ Done |
| 14 | Snapshots & viewer | Always-HD snapshot, snapshot browser, built-in viewer + basic editor | ✅ Done |
| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | 📋 Planned |
| 15 | Local AI analytics | ONNX object detection per camera, auto-record, control center, CPU fallback | ✅ Done |
| 16 | Archive pro | Fragmented MP4, activity calendar, timeline zoom, clip export | 📋 Planned |
| 17 | Community & app-level | Tabbed layouts, config export/import, notifications, white-label, issue reporter, RBAC | 📋 Planned |
| 18 | Streq remote access | Cloud multistreaming across devices: LAN/overlay/relay routing, enrollment, WebRTC/HLS, cross-device sync | 📋 Planned |
Expand Down
30 changes: 30 additions & 0 deletions src/OpenIPC.Viewer.Analytics/ExecutionProviderChain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using OpenIPC.Viewer.Core.Analytics;

namespace OpenIPC.Viewer.Analytics;

// The ordered list of execution providers to try for the current platform,
// most-preferred first. CPU is always the implicit final fallback (the base
// ONNX Runtime package registers it on every RID) so it is not listed here.
//
// Only providers reachable through the generic AppendExecutionProvider(string)
// API are listed — they no-op/throw gracefully when their native EP isn't
// compiled into the referenced package, and we catch + fall back. DirectML and
// CUDA need their dedicated packages + AppendExecutionProvider_DML/_CUDA calls
// wired in the platform composition; until those are added per head, Windows
// and Linux run on CPU.
internal static class ExecutionProviderChain
{
public static IReadOnlyList<(string Name, ExecutionProvider Provider)> ForCurrentPlatform()
{
if (OperatingSystem.IsAndroid())
return new[] { ("NNAPI", ExecutionProvider.NnApi), ("XNNPACK", ExecutionProvider.Xnnpack) };

if (OperatingSystem.IsIOS() || OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst())
return new[] { ("CoreML", ExecutionProvider.CoreMl) };

// Windows / Linux: CPU only for now (GPU EPs are a per-head package job).
return Array.Empty<(string, ExecutionProvider)>();
}
}
30 changes: 30 additions & 0 deletions src/OpenIPC.Viewer.Analytics/ModelCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using OpenIPC.Viewer.Core.Analytics;

namespace OpenIPC.Viewer.Analytics;

// A downloadable detection model: where to fetch it, its integrity hash, and
// how to build the ModelSpec once it is on disk.
public sealed record ModelDescriptor(
string Name,
string FileName,
Uri? DownloadUri,
string? Sha256Hex,
Func<string, ModelSpec> CreateSpec);

// Known models. We ship Apache-2.0 YOLOX (never AGPL Ultralytics YOLO) to stay
// MIT/store compatible. The model is an asset fetched on first enable, not
// checked into the repo.
public static class ModelCatalog
{
// Official YOLOX-tiny ONNX export from the upstream release assets
// (~20 MB). SHA-256 verified against the 0.1.1rc0 asset on 2026-06-19.
public static ModelDescriptor YoloxTiny { get; } = new(
Name: "YOLOX-tiny",
FileName: "yolox_tiny.onnx",
DownloadUri: new Uri("https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_tiny.onnx"),
Sha256Hex: "427CC366D34E27FF7A03E2899B5E3671425C262EA2291F88BB942BC1CC70B0F7",
CreateSpec: ModelSpec.YoloxTiny);

public static ModelDescriptor Default => YoloxTiny;
}
109 changes: 109 additions & 0 deletions src/OpenIPC.Viewer.Analytics/ModelProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIPC.Viewer.Core.Analytics;
using OpenIPC.Viewer.Core.Platform;

namespace OpenIPC.Viewer.Analytics;

// Resolves the detection model file (Phase 15 — download on first enable, cache
// in AppData, verify integrity, repo stays binary-free). Resolution order:
// 1. OPENIPC_DETECTION_MODEL env var (explicit local override / CI fixture),
// 2. cached file in {AppData}/models (re-verified if a hash is pinned),
// 3. download from the descriptor URI into the cache.
public sealed class ModelProvider : IModelProvider
{
private readonly IFileSystem _fs;
private readonly ILogger<ModelProvider> _log;
private readonly ModelDescriptor _descriptor;
private readonly Func<HttpClient> _httpFactory;
private readonly SemaphoreSlim _gate = new(1, 1);

public ModelProvider(IFileSystem fs, ILogger<ModelProvider> log)
: this(fs, log, ModelCatalog.Default, () => new HttpClient { Timeout = TimeSpan.FromMinutes(5) })
{
}

internal ModelProvider(IFileSystem fs, ILogger<ModelProvider> log,
ModelDescriptor descriptor, Func<HttpClient> httpFactory)
{
_fs = fs;
_log = log;
_descriptor = descriptor;
_httpFactory = httpFactory;
}

public async Task<ModelSpec> EnsureModelAsync(CancellationToken ct)
{
var overridePath = Environment.GetEnvironmentVariable("OPENIPC_DETECTION_MODEL");
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
{
_log.LogInformation("Using detection model override: {Path}", overridePath);
return _descriptor.CreateSpec(overridePath);
}

await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var modelsDir = Directory.CreateDirectory(Path.Combine(_fs.AppDataDir.FullName, "models"));
var target = Path.Combine(modelsDir.FullName, _descriptor.FileName);

if (File.Exists(target) && await VerifyAsync(target, ct).ConfigureAwait(false))
return _descriptor.CreateSpec(target);

if (_descriptor.DownloadUri is null)
throw new InvalidOperationException(
$"Model {_descriptor.Name} is missing and no download URI is configured.");

await DownloadAsync(_descriptor.DownloadUri, target, ct).ConfigureAwait(false);

if (!await VerifyAsync(target, ct).ConfigureAwait(false))
{
File.Delete(target);
throw new InvalidOperationException(
$"Downloaded model {_descriptor.Name} failed its integrity check.");
}

return _descriptor.CreateSpec(target);
}
finally
{
_gate.Release();
}
}

private async Task<bool> VerifyAsync(string path, CancellationToken ct)
{
if (_descriptor.Sha256Hex is null) return true; // not pinned yet
using var sha = SHA256.Create();
await using var stream = File.OpenRead(path);
var hash = await sha.ComputeHashAsync(stream, ct).ConfigureAwait(false);
var hex = Convert.ToHexString(hash);
var ok = string.Equals(hex, _descriptor.Sha256Hex, StringComparison.OrdinalIgnoreCase);
if (!ok)
_log.LogWarning("Model {Name} sha256 mismatch (expected {Expected}, got {Actual}).",
_descriptor.Name, _descriptor.Sha256Hex, hex);
return ok;
}

private async Task DownloadAsync(Uri uri, string target, CancellationToken ct)
{
_log.LogInformation("Downloading detection model {Name} from {Uri}", _descriptor.Name, uri);
using var http = _httpFactory();
using var resp = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();

var tmp = target + ".part";
await using (var dst = File.Create(tmp))
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
{
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
}
File.Move(tmp, target, overwrite: true);
_log.LogInformation("Detection model {Name} cached at {Path}", _descriptor.Name, target);
}
}
Loading
Loading