Dr-Swopt 2 днів тому
батько
коміт
4c15767c6f

+ 62 - 0
CLAUDE.md

@@ -0,0 +1,62 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What This Is
+
+PalmOilAI — an Angular 20 single-page application for palm oil fruit bunch (FFB) ripeness detection. All AI inference runs **100% client-side** in the browser using ONNX Runtime Web and TensorFlow.js TFLite. There is no required backend dependency; the NestJS backend is optional and used only for server-side inference and chat proxy.
+
+Detection classes: `Ripe`, `Unripe`, `Underripe`, `Overripe`, `Abnormal`, `Empty_Bunch`.
+
+## Commands
+
+```bash
+npm install
+ng serve                        # Dev server at http://localhost:4200
+ng serve --host 0.0.0.0         # Expose to LAN (device testing)
+ng build                        # Production build → dist/
+ng test                         # Karma + Jasmine unit tests
+```
+
+## Architecture
+
+### Routing
+`/analyzer` → `AnalyzerComponent` (main scanner UI, default route)  
+`/history` → `HistoryComponent` (local vault of past scans)  
+`/chatbot` → `ChatbotComponent` (chat interface backed by n8n via WebSocket)
+
+### Key Services (`src/app/services/`)
+
+**`LocalInferenceService`** — Core AI engine. Dispatches to ONNX or TFLite backend based on model file extension:
+- ONNX path: `onnxruntime-web` with WASM execution provider. Input: `[1, 3, 640, 640]` (CHW).
+- TFLite path: Uses the globally-injected `window.tflite` object. Input is transposed CHW→HWC to `[1, 640, 640, 3]` before prediction.
+
+**`ImageProcessorService`** — Resizes any image to 640×640 via offscreen Canvas, then converts RGBA→CHW `Float32Array` normalized to `[0.0, 1.0]`.
+
+**`LocalHistoryService`** — Persists up to 20 scan records (FIFO) to `localStorage` key `palm_oil_vault`. Each record includes detection summary, latency, engine type, Base64 thumbnail, and bounding boxes.
+
+**`VisionSocketService`** / **`ChatSocketService`** — WebSocket clients connecting to the NestJS backend (`/vision` and unspecified chat namespace respectively).
+
+**`SurveillanceService`** (frontend) — Connects to the NestJS `/monitor` namespace for live CPU/memory metrics of NestJS, n8n, and Ollama processes.
+
+### TFLite Bundler Constraint
+`@tensorflow/tfjs-tflite` is a legacy CommonJS/UMD hybrid incompatible with the Vite/esbuild ESM bundler. Both `tf.min.js` and `tf-tflite.min.js` are loaded as **global scripts** in `angular.json`, not as ES modules. This populates `window.tflite` and `window.tf` before Angular bootstraps. Do not attempt to import them via `import` statements.
+
+### Required Manual Assets (not installed by npm)
+These binary files must be placed manually after `npm install`:
+
+| Path | Source |
+|---|---|
+| `src/assets/models/onnx/best.onnx` | YOLOv8 model file |
+| `src/assets/models/tflite/best_float32.tflite` | Full-precision TFLite model |
+| `src/assets/models/tflite/best_float16.tflite` | Half-precision TFLite model |
+| `src/assets/wasm/` | Copy from `node_modules/onnxruntime-web/dist/` |
+| `src/assets/tflite-wasm/` | Copy 7 files from `node_modules/@tensorflow/tfjs-tflite/dist/` |
+
+### Inference Pipeline (AnalyzerComponent orchestrates)
+1. User uploads image → `ImageProcessorService.processImage()` → CHW Float32Array
+2. `LocalInferenceService.loadModel(modelPath)` → creates ONNX session or loads TFLite model
+3. `LocalInferenceService.runInference(input)` → raw output tensor
+4. `LocalInferenceService.parseDetections(rawData, threshold)` → filtered detections with class labels and bounding boxes
+5. `AnalyzerComponent` draws bounding boxes on Canvas
+6. `LocalHistoryService.save()` → persists to localStorage

+ 20 - 1
src/app/components/analyzer/analyzer.component.html

@@ -41,7 +41,7 @@
              (dragover)="onDragOver($event)"
              (dragleave)="onDragLeave($event)"
              (drop)="onDrop($event)">
-          <input type="file" #fileInput (change)="onFileSelected($event)" accept="image/*">
+          <input type="file" #fileInput multiple (change)="onFileSelected($event)" accept="image/*">
           <div class="upload-icon">📁</div>
           @if (!selectedFile) { <p>Drop image here or click to upload</p> }
           @if (selectedFile)  { <p>{{ selectedFile.name }}</p> }
@@ -147,6 +147,25 @@
             </span>
           </div>
 
+          <!-- Batch Status HUD — visible only when a batch queue is loaded -->
+          @if (isBatchActive()) {
+            <div class="batch-status-hud glass-panel">
+              <div class="batch-status-label">
+                Batch Status — Processing Image {{ currentBatchIndex() + 1 }} of {{ totalBatchCount }}
+              </div>
+              <div class="batch-progress-row">
+                <div class="batch-progress-bar">
+                  <div class="batch-progress-fill"
+                       [style.width.%]="(currentBatchIndex() / totalBatchCount) * 100">
+                  </div>
+                </div>
+                <button class="btn btn-danger btn-sm abort-btn" (click)="abortBatch()">
+                  Abort Batch
+                </button>
+              </div>
+            </div>
+          }
+
           <!-- Input source toggle -->
           <div class="input-mode-toggle">
             <button

+ 48 - 0
src/app/components/analyzer/analyzer.component.scss

@@ -513,3 +513,51 @@
   cursor: not-allowed;
   pointer-events: none;
 }
+
+// ── Batch Status HUD ──────────────────────────────────────────────────────────
+.batch-status-hud {
+  padding: 12px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.batch-status-label {
+  font-size: 13px;
+  color: var(--text-secondary);
+  font-weight: 500;
+}
+
+.batch-progress-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.batch-progress-bar {
+  flex: 1;
+  height: 8px;
+  background: var(--input-bg, #2a2a2a);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.batch-progress-fill {
+  height: 100%;
+  background: var(--accent, #00A651);
+  border-radius: 4px;
+  transition: width 0.3s ease;
+}
+
+.abort-btn {
+  white-space: nowrap;
+  padding: 4px 10px;
+  font-size: 12px;
+  background: #DC3545;
+  color: #fff;
+  border: none;
+  border-radius: 6px;
+  cursor: pointer;
+
+  &:hover { background: #b02a37; }
+}

+ 47 - 2
src/app/components/analyzer/analyzer.component.ts

@@ -68,6 +68,10 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
 
   // ── Engine selection ───────────────────────────────────────────────────────
   engine = signal<SnapEngine>('onnx');
+  /** 'local' for browser engines, 'backend' for socket */
+  engineMode = computed<'local' | 'backend'>(() =>
+    this.engine() === 'socket' ? 'backend' : 'local'
+  );
 
   // ── Browser-engine state ───────────────────────────────────────────────────
   selectedFile: File | null = null;
@@ -76,6 +80,15 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   loading = false;
   isDragging = false;
 
+  // ── Batch ingestion state (backend/socket mode only) ───────────────────────
+  batchQueue = signal<File[]>([]);
+  currentBatchIndex = signal<number>(0);
+  isBatchActive = computed(() => this.batchQueue().length > 0);
+  /** Accumulates per-image results for 4.2/4.3 audit trail */
+  sessionManifest: any[] = [];
+
+  get totalBatchCount(): number { return this.batchQueue().length + this.currentBatchIndex(); }
+
   // ── Socket-engine state ────────────────────────────────────────────────────
   /** 'webcam' = live camera snap | 'gallery' = file from device storage */
   socketInputMode = signal<'webcam' | 'gallery'>('webcam');
@@ -99,6 +112,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
         this.stopWebcam();
       }
     });
+
+    // Safety: switching to local while a batch is queued must clear the queue
+    // immediately — local WASM cannot handle multi-file sequential load
+    effect(() => {
+      if (this.engineMode() === 'local' && this.isBatchActive()) {
+        this.abortBatch();
+      }
+    });
   }
 
   ngOnInit(): void {}
@@ -127,8 +148,32 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   // ── Browser engine — file upload ───────────────────────────────────────────
 
   onFileSelected(event: any): void {
-    const file = event.target.files[0];
-    if (file) this.handleFile(file);
+    const files: FileList = event.target.files;
+    if (!files || files.length === 0) return;
+
+    if (this.engineMode() === 'local') {
+      this.processSingleFile(files[0]);
+    } else {
+      this.batchQueue.set(Array.from(files));
+      this.currentBatchIndex.set(0);
+      this.sessionManifest = [];
+      this.startBatchProcessing();
+    }
+  }
+
+  private processSingleFile(file: File): void {
+    this.handleFile(file);
+  }
+
+  /** Placeholder — actual network loop is Task 4.2. Do NOT send files here. */
+  private startBatchProcessing(): void {
+    // Queue is loaded; progress HUD is now visible via isBatchActive().
+    // Task 4.2 will drive the sequential sendBase64() loop from here.
+  }
+
+  abortBatch(): void {
+    this.batchQueue.set([]);
+    this.currentBatchIndex.set(0);
   }
 
   onDragOver(event: DragEvent): void {