|
@@ -0,0 +1,207 @@
|
|
|
|
|
+import { Injectable, OnDestroy } from '@angular/core';
|
|
|
|
|
+import { Observable, Subject, BehaviorSubject } from 'rxjs';
|
|
|
|
|
+
|
|
|
|
|
+// ── MPOB standard detection classes (indices 0–5) ───────────────────────────
|
|
|
|
|
+export const MPOB_CLASSES: string[] = [
|
|
|
|
|
+ 'Empty_Bunch',
|
|
|
|
|
+ 'Underripe',
|
|
|
|
|
+ 'Abnormal',
|
|
|
|
|
+ 'Ripe',
|
|
|
|
|
+ 'Unripe',
|
|
|
|
|
+ 'Overripe',
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+export const HEALTH_ALERT_CLASSES: string[] = ['Abnormal', 'Empty_Bunch'];
|
|
|
|
|
+
|
|
|
|
|
+// ── Domain types ─────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+export interface DetectionResult {
|
|
|
|
|
+ bunch_id: number;
|
|
|
|
|
+ class: string;
|
|
|
|
|
+ confidence: number;
|
|
|
|
|
+ is_health_alert: boolean;
|
|
|
|
|
+ box: [number, number, number, number];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface InferenceFrame {
|
|
|
|
|
+ frameId: string;
|
|
|
|
|
+ batchId?: string;
|
|
|
|
|
+ imageDataUrl: string;
|
|
|
|
|
+ detections: DetectionResult[];
|
|
|
|
|
+ inference_ms: number;
|
|
|
|
|
+ processing_ms: number;
|
|
|
|
|
+ total_count: number;
|
|
|
|
|
+ industrial_summary: Record<string, number>;
|
|
|
|
|
+ source: 'wasm-local';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Preprocessing constants ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+const MODEL_INPUT_SIZE = 640;
|
|
|
|
|
+
|
|
|
|
|
+@Injectable({ providedIn: 'root' })
|
|
|
|
|
+export class InferenceService implements OnDestroy {
|
|
|
|
|
+ /** Emits each completed inference frame to subscribers */
|
|
|
|
|
+ readonly results$ = new Subject<InferenceFrame>();
|
|
|
|
|
+
|
|
|
|
|
+ /** Tracks number of frames pending in the processing queue */
|
|
|
|
|
+ readonly queueDepth$ = new BehaviorSubject<number>(0);
|
|
|
|
|
+
|
|
|
|
|
+ private worker: Worker | null = null;
|
|
|
|
|
+ private destroyed$ = new Subject<void>();
|
|
|
|
|
+ private pendingMap = new Map<string, (frame: InferenceFrame) => void>();
|
|
|
|
|
+
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ this.initWorker();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Submit a file for local WASM inference.
|
|
|
|
|
+ * Preprocessing runs synchronously on the calling thread; ONNX execution
|
|
|
|
|
+ * is dispatched to the background worker.
|
|
|
|
|
+ */
|
|
|
|
|
+ analyze(file: File, batchId?: string): Observable<InferenceFrame> {
|
|
|
|
|
+ return new Observable<InferenceFrame>(observer => {
|
|
|
|
|
+ const frameId = crypto.randomUUID();
|
|
|
|
|
+ const processingStart = performance.now();
|
|
|
|
|
+
|
|
|
|
|
+ this.queueDepth$.next(this.queueDepth$.value + 1);
|
|
|
|
|
+
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ reader.onload = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const imageDataUrl = reader.result as string;
|
|
|
|
|
+ const tensor = await this.preprocessImage(imageDataUrl);
|
|
|
|
|
+
|
|
|
|
|
+ if (this.worker) {
|
|
|
|
|
+ this.pendingMap.set(frameId, (frame) => {
|
|
|
|
|
+ this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
|
|
|
|
|
+ observer.next(frame);
|
|
|
|
|
+ observer.complete();
|
|
|
|
|
+ this.results$.next(frame);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ this.worker.postMessage({
|
|
|
|
|
+ frameId,
|
|
|
|
|
+ batchId,
|
|
|
|
|
+ imageDataUrl,
|
|
|
|
|
+ tensor: tensor.buffer,
|
|
|
|
|
+ processingStart,
|
|
|
|
|
+ }, [tensor.buffer]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Worker unavailable — emit empty frame so callers can handle gracefully
|
|
|
|
|
+ const frame: InferenceFrame = this.buildEmptyFrame(
|
|
|
|
|
+ frameId, batchId, imageDataUrl, processingStart
|
|
|
|
|
+ );
|
|
|
|
|
+ this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
|
|
|
|
|
+ observer.next(frame);
|
|
|
|
|
+ observer.complete();
|
|
|
|
|
+ this.results$.next(frame);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
|
|
|
|
|
+ observer.error(err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.onerror = () => {
|
|
|
|
|
+ this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
|
|
|
|
|
+ observer.error(new Error('FileReader failed to read image'));
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.readAsDataURL(file);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Decode image, resize to 640×640, strip alpha, normalize [0,1], return CHW Float32Array.
|
|
|
|
|
+ * Output shape: [1, 3, 640, 640]
|
|
|
|
|
+ */
|
|
|
|
|
+ async preprocessImage(imageDataUrl: string): Promise<Float32Array> {
|
|
|
|
|
+ const img = await this.loadImage(imageDataUrl);
|
|
|
|
|
+
|
|
|
|
|
+ const canvas = document.createElement('canvas');
|
|
|
|
|
+ canvas.width = MODEL_INPUT_SIZE;
|
|
|
|
|
+ canvas.height = MODEL_INPUT_SIZE;
|
|
|
|
|
+ const ctx = canvas.getContext('2d')!;
|
|
|
|
|
+ ctx.drawImage(img, 0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
|
|
|
|
|
+
|
|
|
|
|
+ const { data } = ctx.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
|
|
|
|
|
+ const pixelCount = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE;
|
|
|
|
|
+ const tensor = new Float32Array(3 * pixelCount);
|
|
|
|
|
+
|
|
|
|
|
+ // RGBA → CHW: R channel, then G channel, then B channel
|
|
|
|
|
+ for (let i = 0; i < pixelCount; i++) {
|
|
|
|
|
+ tensor[i] = data[i * 4] / 255.0; // R
|
|
|
|
|
+ tensor[pixelCount + i] = data[i * 4 + 1] / 255.0; // G
|
|
|
|
|
+ tensor[2 * pixelCount + i] = data[i * 4 + 2] / 255.0; // B
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return tensor;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.destroyed$.next();
|
|
|
|
|
+ this.destroyed$.complete();
|
|
|
|
|
+ this.worker?.terminate();
|
|
|
|
|
+ this.worker = null;
|
|
|
|
|
+ this.results$.complete();
|
|
|
|
|
+ this.queueDepth$.complete();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Private helpers ────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ private initWorker(): void {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Worker script is expected at assets/workers/inference.worker.js
|
|
|
|
|
+ // Built separately; graceful degradation if absent
|
|
|
|
|
+ this.worker = new Worker(
|
|
|
|
|
+ new URL('../workers/inference.worker', import.meta.url),
|
|
|
|
|
+ { type: 'module' }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ this.worker.onmessage = ({ data }: MessageEvent<InferenceFrame>) => {
|
|
|
|
|
+ const resolve = this.pendingMap.get(data.frameId);
|
|
|
|
|
+ if (resolve) {
|
|
|
|
|
+ this.pendingMap.delete(data.frameId);
|
|
|
|
|
+ resolve(data);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ this.worker.onerror = (err) => {
|
|
|
|
|
+ console.warn('[InferenceService] Worker error — falling back to no-op mode', err);
|
|
|
|
|
+ this.worker = null;
|
|
|
|
|
+ this.pendingMap.clear();
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Worker URL may not exist during initial scaffolding — safe to ignore
|
|
|
|
|
+ this.worker = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const img = new Image();
|
|
|
|
|
+ img.onload = () => resolve(img);
|
|
|
|
|
+ img.onerror = reject;
|
|
|
|
|
+ img.src = dataUrl;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildEmptyFrame(
|
|
|
|
|
+ frameId: string,
|
|
|
|
|
+ batchId: string | undefined,
|
|
|
|
|
+ imageDataUrl: string,
|
|
|
|
|
+ processingStart: number,
|
|
|
|
|
+ ): InferenceFrame {
|
|
|
|
|
+ return {
|
|
|
|
|
+ frameId,
|
|
|
|
|
+ batchId,
|
|
|
|
|
+ imageDataUrl,
|
|
|
|
|
+ detections: [],
|
|
|
|
|
+ inference_ms: 0,
|
|
|
|
|
+ processing_ms: performance.now() - processingStart,
|
|
|
|
|
+ total_count: 0,
|
|
|
|
|
+ industrial_summary: {},
|
|
|
|
|
+ source: 'wasm-local',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|