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
410 changes: 410 additions & 0 deletions Features/PhysicalAssets/LiveCameraScanner.razor

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions Features/PhysicalAssets/LiveCameraScanner.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
.camera-scanner {
position: relative;
width: 100%;
margin-bottom: 1.5rem;
}

.viewfinder-stage {
position: relative;
width: 100%;
max-width: 720px;
margin: 0 auto;
background-color: #0b0f19;
border-radius: 0.75rem;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
overscroll-behavior: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
cursor: crosshair;
}

.camera-video,
.freeze-canvas {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}

.freeze-canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}

.contour-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}

.glowing-contour {
fill: rgba(0, 220, 255, 0.25);
stroke: #00dcff;
stroke-width: 0.8;
stroke-linejoin: round;
stroke-linecap: round;
filter: drop-shadow(0 0 6px rgba(0, 220, 255, 0.85))
drop-shadow(0 0 12px rgba(0, 220, 255, 0.45));
animation: contour-pulse 2s ease-in-out infinite alternate;
}

@keyframes contour-pulse {
0% {
fill: rgba(0, 220, 255, 0.2);
stroke: #00dcff;
filter: drop-shadow(0 0 4px rgba(0, 220, 255, 0.7));
}
100% {
fill: rgba(0, 220, 255, 0.35);
stroke: #33e6ff;
filter: drop-shadow(0 0 8px rgba(0, 220, 255, 1))
drop-shadow(0 0 16px rgba(0, 220, 255, 0.6));
}
}

.prompt-marker {
stroke-width: 0.4;
transition: transform 0.15s ease-out;
}

.prompt-marker.positive {
fill: #10b981;
stroke: #ffffff;
filter: drop-shadow(0 0 3px rgba(16, 185, 129, 0.9));
}

.prompt-marker.negative {
fill: #ef4444;
stroke: #ffffff;
filter: drop-shadow(0 0 3px rgba(239, 68, 68, 0.9));
}

.viewfinder-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
background-color: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(4px);
color: #f8fafc;
padding: 0.35rem 0.75rem;
border-radius: 9999px;
font-size: 0.85rem;
pointer-events: none;
white-space: nowrap;
}

.viewfinder-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #94a3b8;
padding: 2rem;
text-align: center;
}

.action-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1rem;
}

.mode-toggle {
display: inline-flex;
border-radius: 0.375rem;
overflow: hidden;
border: 1px solid #cbd5e1;
}

.mode-btn {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
border: none;
background-color: #f8fafc;
color: #334155;
cursor: pointer;
}

.mode-btn.active.positive {
background-color: #10b981;
color: #ffffff;
font-weight: 600;
}

.mode-btn.active.negative {
background-color: #ef4444;
color: #ffffff;
font-weight: 600;
}
51 changes: 41 additions & 10 deletions Features/PhysicalAssets/PhysicalAssets.razor
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@
<header class="page-header">
<div>
<h1>Physical Assets</h1>
<p class="page-subtitle">Scan a photo or short video to identify and value your possessions.</p>
<p class="page-subtitle">Scan a photo or point your camera at possessions to identify and value them in real time.</p>
</div>
</header>

<section class="page-card">
<h2 class="page-section-title">Scan a possession</h2>
<label for="possession-photo" class="btn btn-primary @(IsWorking || _videoBusy ? "disabled" : "")">Choose a photo</label>
<InputFile id="possession-photo" class="visually-hidden" OnChange="OnFileSelectedAsync" accept="image/*" capture="environment" disabled="@(IsWorking || _videoBusy)" /> </section>
<label for="possession-photo" class="btn btn-primary @(IsWorking ? "disabled" : "")">Choose a photo</label>
<InputFile id="possession-photo" class="visually-hidden" OnChange="OnFileSelectedAsync" accept="image/*" capture="environment" disabled="@IsWorking" /> </section>

<LiveCameraScanner Disabled="@IsWorking" OnItemAccepted="ReviewLiveScannedItem" BusyChanged="@(busy => _cameraBusy = busy)" />

@if (ShowVideoScan)
{
<VideoScanPicker Disabled="@IsWorking" OnSelected="ReviewVideoItems" BusyChanged="@(busy => _videoBusy = busy)" />
}

@if (_isProcessing)
{
Expand Down Expand Up @@ -144,7 +149,7 @@
</div>
</div>

<button class="btn btn-outline-secondary btn-sm mt-2" @onclick="() => EstimateValueAsync(detection)" disabled="@(IsWorking || _videoBusy || detection.SavedItemId is not null)">
<button class="btn btn-outline-secondary btn-sm mt-2" @onclick="() => EstimateValueAsync(detection)" disabled="@(IsWorking || detection.SavedItemId is not null)">
@(detection.IsEstimating ? "Estimating value with AI..." : "Estimate value")
</button>

Expand Down Expand Up @@ -182,7 +187,7 @@
type="button"
class="btn btn-outline-warning btn-sm me-2 mb-1"
@onclick="() => SaveToInventoryAsync(detection, mergeIntoId: duplicate.Id)"
disabled="@(IsWorking || _videoBusy)">
disabled="@IsWorking">
Merge into "@duplicate.Name"
</button>
}
Expand All @@ -192,7 +197,7 @@
<button
class="btn btn-success btn-sm mt-2"
@onclick="() => SaveToInventoryAsync(detection, mergeIntoId: null)"
disabled="@(IsWorking || _videoBusy)">
disabled="@IsWorking">
@(detection.IsSaving ? "Saving..." : "Save as new item")
</button>

Expand All @@ -217,10 +222,13 @@
@code {
private const double LowConfidenceThreshold = 0.6;
private const double HighConfidenceThreshold = 0.8;
[Parameter]
public bool ShowVideoScan { get; set; }

private bool _isProcessing;
private bool _cameraBusy;
private bool _videoBusy;
private bool IsWorking => _isProcessing || (_detections?.Any(d => d.IsSaving || d.IsEstimating) ?? false);
private bool IsWorking => _isProcessing || _cameraBusy || (_videoBusy && ShowVideoScan) || (_detections?.Any(d => d.IsSaving || d.IsEstimating) ?? false);
private string? _processingMessage;
private string? _errorMessage;
private string? _errorRawResponse;
Expand All @@ -231,6 +239,8 @@
private string _confidenceFilter = "all";
private string _sortOrder = "confidence-desc";



private void ReviewVideoItems(IReadOnlyList<SelectedVideoAsset> items)
{
ClearError();
Expand All @@ -239,6 +249,27 @@
_sortOrder = "confidence-desc";
_detections = items.Select(item => new ReviewableDetection(item.Detection) { ImageReference = item.ImageReference }).ToList();
}
private void ReviewLiveScannedItem(LiveScannedItem item)
{
ClearError();
_imageReference = null;
_categoryFilter = _confidenceFilter = "all";
_sortOrder = "confidence-desc";

var detection = new DetectedAsset(
"Scanned item",
item.Confidence,
item.Region,
null,
[],
item.Mask
);

_detections = new List<ReviewableDetection>
{
new(detection) { ImageReference = item.ImageReference, Included = true }
};
}

private IReadOnlyList<string> CategoryOptions => _detections is null
? Array.Empty<string>()
Expand Down Expand Up @@ -315,7 +346,7 @@

private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
{
if (IsWorking || _videoBusy) return;
if (IsWorking) return;
ClearError();
_imageReference = null;
_detections = null;
Expand Down Expand Up @@ -379,7 +410,7 @@

private async Task EstimateValueAsync(ReviewableDetection detection)
{
if (IsWorking || _videoBusy || detection.SavedItemId is not null) return;
if (IsWorking || detection.SavedItemId is not null) return;
detection.ValuationError = null;
detection.ValuationRawResponse = null;
detection.IsEstimating = true;
Expand Down Expand Up @@ -419,7 +450,7 @@
// were found.
private async Task SaveToInventoryAsync(ReviewableDetection detection, Guid? mergeIntoId)
{
if (detection.Valuation?.EstimatedValueUsd is not { } value || IsWorking || _videoBusy || detection.SavedItemId is not null)
if (detection.Valuation?.EstimatedValueUsd is not { } value || IsWorking || detection.SavedItemId is not null)
{
return;
}
Expand Down
11 changes: 10 additions & 1 deletion PhysicalAssets/SamSegmentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ int Height

public record SamEncodeResult(double ElapsedMs, string? Device, int Width, int Height);

public record LiveScannedItem(
IReadOnlyList<NormalizedPoint> Mask,
BoundingBox Region,
double Confidence,
int Width,
int Height,
string? ImageReference = null
);

public interface ISamSegmentationEngine : IAsyncDisposable
{
ValueTask<SamEngineStatus> InitializeAsync(CancellationToken cancellationToken = default);
Expand Down Expand Up @@ -132,7 +141,7 @@ public async ValueTask<SamSegmentationResult> DecodePointsAsync(
return await module.InvokeAsync<SamSegmentationResult>(
"decodeMultiPoints",
cancellationToken,
jsPoints
new object[] { jsPoints }
);
}

Expand Down
4 changes: 4 additions & 0 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

// Add services to the container.
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.Configure<Microsoft.AspNetCore.SignalR.HubOptions>(options =>
{
options.MaximumReceiveMessageSize = 512 * 1024;
});

builder.Services.Configure<NemotronOptions>(
builder.Configuration.GetSection(NemotronOptions.SectionName)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
"description": "Dev tooling for Money Mirror (formatters only — the app itself is ASP.NET Core).",
"license": "UNLICENSED",
"scripts": {
"test": "npm run test:video && npm run test:sam",
"test": "npm run test:video && npm run test:sam && npm run test:camera",
"test:video": "node --test tests/js/video-scan.test.mjs",
"test:sam": "node --test tests/js/mobile-sam.test.mjs",
"test:camera": "node --test tests/js/camera-scanner.test.mjs",
"format": "npm run format:cs && npm run format:css",
"format:cs": "dotnet csharpier format .",
"format:css": "prettier --write \"**/*.css\"",
Expand Down
Loading
Loading