|
@@ -1,8 +1,16 @@
|
|
|
-import { Component } from '@angular/core';
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ AfterViewInit,
|
|
|
|
|
+ Component,
|
|
|
|
|
+ ElementRef,
|
|
|
|
|
+ OnDestroy,
|
|
|
|
|
+ OnInit,
|
|
|
|
|
+ ViewChild,
|
|
|
|
|
+} from '@angular/core';
|
|
|
import { AsyncPipe, DecimalPipe, KeyValuePipe, NgClass, NgStyle } from '@angular/common';
|
|
import { AsyncPipe, DecimalPipe, KeyValuePipe, NgClass, NgStyle } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { Select, Store } from '@ngxs/store';
|
|
import { Select, Store } from '@ngxs/store';
|
|
|
-import { Observable } from 'rxjs';
|
|
|
|
|
|
|
+import { Observable, Subscription } from 'rxjs';
|
|
|
|
|
+import { filter } from 'rxjs/operators';
|
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
@@ -13,6 +21,8 @@ import { VisionState } from '../store/vision.state';
|
|
|
import { SubmitBatchAnalysis } from '../store/vision.actions';
|
|
import { SubmitBatchAnalysis } from '../store/vision.actions';
|
|
|
import { InferenceFrame } from '../services/inference.service';
|
|
import { InferenceFrame } from '../services/inference.service';
|
|
|
|
|
|
|
|
|
|
+type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
|
|
|
|
|
+
|
|
|
@Component({
|
|
@Component({
|
|
|
selector: 'app-analyzer',
|
|
selector: 'app-analyzer',
|
|
|
standalone: true,
|
|
standalone: true,
|
|
@@ -33,15 +43,62 @@ import { InferenceFrame } from '../services/inference.service';
|
|
|
templateUrl: './analyzer.component.html',
|
|
templateUrl: './analyzer.component.html',
|
|
|
styleUrl: './analyzer.component.scss',
|
|
styleUrl: './analyzer.component.scss',
|
|
|
})
|
|
})
|
|
|
-export class AnalyzerComponent {
|
|
|
|
|
|
|
+export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
|
@Select(VisionState.loading) loading$!: Observable<boolean>;
|
|
@Select(VisionState.loading) loading$!: Observable<boolean>;
|
|
|
- @Select(VisionState.currentInference) currentInference$!: Observable<InferenceFrame | null>;
|
|
|
|
|
|
|
|
|
|
- mode: 'local' | 'remote' = 'remote';
|
|
|
|
|
|
|
+ @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
|
|
+ @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
|
|
|
|
|
+
|
|
|
|
|
+ mode: EngineMode = 'remote';
|
|
|
|
|
+ isViewInitialized = false;
|
|
|
|
|
+ inputMode: 'file' | 'camera' = 'file';
|
|
|
isDragOver = false;
|
|
isDragOver = false;
|
|
|
|
|
+ isCameraActive = false;
|
|
|
|
|
+ currentFrame: InferenceFrame | null = null;
|
|
|
|
|
+
|
|
|
|
|
+ readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
|
|
|
|
|
+ { value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
|
|
|
|
|
+ { value: 'local-tflite', label: 'Local — TFLite WASM', icon: 'layers' },
|
|
|
|
|
+ { value: 'remote', label: 'Remote — NestJS Server', icon: 'cloud' },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ private mediaStream: MediaStream | null = null;
|
|
|
|
|
+ private inferenceSub!: Subscription;
|
|
|
|
|
|
|
|
constructor(private store: Store) {}
|
|
constructor(private store: Store) {}
|
|
|
|
|
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ this.inferenceSub = (this.store.select(VisionState.currentInference) as Observable<InferenceFrame | null>)
|
|
|
|
|
+ .pipe(filter((f): f is InferenceFrame => !!f))
|
|
|
|
|
+ .subscribe(frame => {
|
|
|
|
|
+ this.currentFrame = frame;
|
|
|
|
|
+ if (this.isViewInitialized) {
|
|
|
|
|
+ this.renderPredictionsWithBoxes(frame);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngAfterViewInit(): void {
|
|
|
|
|
+ this.isViewInitialized = true;
|
|
|
|
|
+ if (this.currentFrame) {
|
|
|
|
|
+ this.renderPredictionsWithBoxes(this.currentFrame);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.stopCamera();
|
|
|
|
|
+ this.inferenceSub?.unsubscribe();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Input mode ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ switchInputMode(m: 'file' | 'camera'): void {
|
|
|
|
|
+ if (m === 'file') this.stopCamera();
|
|
|
|
|
+ this.inputMode = m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── File drop ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
onFileInput(event: Event): void {
|
|
onFileInput(event: Event): void {
|
|
|
const input = event.target as HTMLInputElement;
|
|
const input = event.target as HTMLInputElement;
|
|
|
if (input.files?.length) {
|
|
if (input.files?.length) {
|
|
@@ -68,6 +125,95 @@ export class AnalyzerComponent {
|
|
|
this.isDragOver = false;
|
|
this.isDragOver = false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Camera ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ async startCamera(): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
+ video: { facingMode: 'environment', width: 640, height: 640 },
|
|
|
|
|
+ });
|
|
|
|
|
+ this.isCameraActive = true;
|
|
|
|
|
+ const video = this.videoElRef?.nativeElement;
|
|
|
|
|
+ if (video) video.srcObject = this.mediaStream;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[Analyzer] Camera access denied:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ stopCamera(): void {
|
|
|
|
|
+ this.mediaStream?.getTracks().forEach(t => t.stop());
|
|
|
|
|
+ this.mediaStream = null;
|
|
|
|
|
+ this.isCameraActive = false;
|
|
|
|
|
+ if (this.videoElRef?.nativeElement) {
|
|
|
|
|
+ this.videoElRef.nativeElement.srcObject = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ captureWebcamFrame(): void {
|
|
|
|
|
+ const video = this.videoElRef?.nativeElement;
|
|
|
|
|
+ if (!video) return;
|
|
|
|
|
+ const offscreen = document.createElement('canvas');
|
|
|
|
|
+ offscreen.width = video.videoWidth || 640;
|
|
|
|
|
+ offscreen.height = video.videoHeight || 640;
|
|
|
|
|
+ offscreen.getContext('2d')!.drawImage(video, 0, 0);
|
|
|
|
|
+ offscreen.toBlob(blob => {
|
|
|
|
|
+ if (!blob) return;
|
|
|
|
|
+ this.submit([new File([blob], `webcam-${Date.now()}.jpg`, { type: 'image/jpeg' })]);
|
|
|
|
|
+ }, 'image/jpeg');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Canvas renderer ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ renderPredictionsWithBoxes(frame: InferenceFrame): void {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ const canvas = this.resultCanvasRef?.nativeElement;
|
|
|
|
|
+ if (!canvas || !frame.imageDataUrl) return;
|
|
|
|
|
+
|
|
|
|
|
+ const img = new Image();
|
|
|
|
|
+ img.onload = () => {
|
|
|
|
|
+ canvas.width = img.naturalWidth;
|
|
|
|
|
+ canvas.height = img.naturalHeight;
|
|
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
|
|
+
|
|
|
|
|
+ for (const det of frame.detections) {
|
|
|
|
|
+ let x: number, y: number, w: number, h: number;
|
|
|
|
|
+ if (det.norm_box) {
|
|
|
|
|
+ const [nx1, ny1, nx2, ny2] = det.norm_box;
|
|
|
|
|
+ x = nx1 * canvas.width;
|
|
|
|
|
+ y = ny1 * canvas.height;
|
|
|
|
|
+ w = (nx2 - nx1) * canvas.width;
|
|
|
|
|
+ h = (ny2 - ny1) * canvas.height;
|
|
|
|
|
+ } else if (det.box) {
|
|
|
|
|
+ const [x1, y1, x2, y2] = det.box;
|
|
|
|
|
+ x = x1;
|
|
|
|
|
+ y = y1;
|
|
|
|
|
+ w = x2 - x1;
|
|
|
|
|
+ h = y2 - y1;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const color = this.gradeColor(det.class);
|
|
|
|
|
+
|
|
|
|
|
+ ctx.strokeStyle = color;
|
|
|
|
|
+ ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.strokeRect(x, y, w, h);
|
|
|
|
|
+
|
|
|
|
|
+ const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
|
|
|
|
|
+ ctx.font = 'bold 12px sans-serif';
|
|
|
|
|
+ const tw = ctx.measureText(label).width;
|
|
|
|
|
+ ctx.fillStyle = color;
|
|
|
|
|
+ ctx.fillRect(x, y - 20, tw + 8, 20);
|
|
|
|
|
+ ctx.fillStyle = '#fff';
|
|
|
|
|
+ ctx.fillText(label, x + 4, y - 5);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ img.src = frame.imageDataUrl;
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
confidencePercent(value: number): string {
|
|
confidencePercent(value: number): string {
|
|
|
return (value * 100).toFixed(1) + '%';
|
|
return (value * 100).toFixed(1) + '%';
|
|
|
}
|
|
}
|