Quellcode durchsuchen

palm oil component

Dr-Swopt vor 1 Woche
Ursprung
Commit
8e2434d036

BIN
best.onnx


Datei-Diff unterdrückt, da er zu groß ist
+ 471 - 53
package-lock.json


+ 5 - 1
package.json

@@ -23,12 +23,15 @@
     "@nestjs/common": "^11.0.1",
     "@nestjs/core": "^11.0.1",
     "@nestjs/platform-express": "^11.0.1",
+    "@nestjs/typeorm": "^11.0.1",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.15.1",
     "onnxruntime-node": "^1.24.3",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.1",
-    "sharp": "^0.34.5"
+    "sharp": "^0.34.5",
+    "sqlite3": "^5.1.7",
+    "typeorm": "^0.3.28"
   },
   "devDependencies": {
     "@eslint/eslintrc": "^3.2.0",
@@ -40,6 +43,7 @@
     "@swc/core": "^1.10.7",
     "@types/express": "^5.0.0",
     "@types/jest": "^29.5.14",
+    "@types/multer": "^2.1.0",
     "@types/node": "^22.10.7",
     "@types/supertest": "^6.0.2",
     "eslint": "^9.18.0",

BIN
palm_history.db


+ 10 - 1
src/app.module.ts

@@ -1,10 +1,19 @@
 import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
 import { AppController } from './app.controller';
 import { AppService } from './app.service';
 import { PalmOilModule } from './palm-oil/palm-oil.module';
 
 @Module({
-  imports: [PalmOilModule],
+  imports: [
+    TypeOrmModule.forRoot({
+      type: 'sqlite',
+      database: 'palm_history.db',
+      autoLoadEntities: true,
+      synchronize: true, // Auto-create tables (use only for development)
+    }),
+    PalmOilModule,
+  ],
   controllers: [AppController],
   providers: [AppService],
 })

+ 31 - 0
src/palm-oil/entities/history.entity.ts

@@ -0,0 +1,31 @@
+import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
+
+@Entity()
+export class History {
+  @PrimaryGeneratedColumn()
+  id: number;
+
+  @Column({ unique: true })
+  archive_id: string;
+
+  @Column()
+  filename: string;
+
+  @Column()
+  total_count: number;
+
+  @Column({ type: 'simple-json' })
+  industrial_summary: Record<string, number>;
+
+  @Column({ type: 'simple-json' })
+  detections: any[];
+
+  @Column({ type: 'float' })
+  inference_ms: number;
+
+  @Column({ type: 'float' })
+  processing_ms: number;
+
+  @CreateDateColumn()
+  created_at: Date;
+}

+ 23 - 2
src/palm-oil/palm-oil.controller.ts

@@ -1,4 +1,25 @@
-import { Controller } from '@nestjs/common';
+import { Controller, Post, Get, UseInterceptors, UploadedFile } from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { PalmOilService } from './palm-oil.service';
+import { AnalysisResponse } from './interfaces/palm-analysis.interface';
 
 @Controller('palm-oil')
-export class PalmOilController {}
+export class PalmOilController {
+  constructor(private readonly palmOilService: PalmOilService) { }
+
+  @Post('analyze')
+  @UseInterceptors(FileInterceptor('image'))
+  async analyze(@UploadedFile() file: Express.Multer.File): Promise<AnalysisResponse> {
+    if (!file) {
+      throw new Error('No image uploaded');
+    }
+    let res = await this.palmOilService.analyzeImage(file.buffer, file.originalname)
+    // console.log(res)
+    return res;
+  }
+
+  @Get('history')
+  async getHistory() {
+    return this.palmOilService.getHistory();
+  }
+}

+ 6 - 1
src/palm-oil/palm-oil.module.ts

@@ -1,9 +1,14 @@
 import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
 import { PalmOilController } from './palm-oil.controller';
 import { PalmOilService } from './palm-oil.service';
+import { ScannerProvider } from './providers/scanner.provider';
+import { History } from './entities/history.entity';
 
 @Module({
+  imports: [TypeOrmModule.forFeature([History])],
   controllers: [PalmOilController],
-  providers: [PalmOilService]
+  providers: [PalmOilService, ScannerProvider],
+  exports: [PalmOilService, ScannerProvider],
 })
 export class PalmOilModule {}

+ 93 - 1
src/palm-oil/palm-oil.service.ts

@@ -1,4 +1,96 @@
 import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { ScannerProvider } from './providers/scanner.provider';
+import * as sharp from 'sharp';
+import { performance } from 'perf_hooks';
+import { AnalysisResponse, IndustrialSummary } from './interfaces/palm-analysis.interface';
+import { History } from './entities/history.entity';
+import { MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './constants/mpob-standards';
 
 @Injectable()
-export class PalmOilService {}
+export class PalmOilService {
+  constructor(
+    private readonly scanner: ScannerProvider,
+    @InjectRepository(History)
+    private readonly historyRepository: Repository<History>,
+  ) {}
+
+  async analyzeImage(imageBuffer: Buffer, originalFilename: string = 'unknown.jpg'): Promise<AnalysisResponse> {
+    const processingStart = performance.now();
+
+    // 1. Get original image dimensions
+    const metadata = await sharp(imageBuffer).metadata();
+    const originalWidth = metadata.width || 640;
+    const originalHeight = metadata.height || 640;
+
+    // 2. Preprocess
+    const tensor = await this.scanner.preprocess(imageBuffer);
+
+    // 3. Inference with timing
+    const inferenceStart = performance.now();
+    const outputTensor = await this.scanner.inference(tensor);
+    const inferenceMs = performance.now() - inferenceStart;
+
+    // 4. Post-process
+    const detections = await this.scanner.postprocess(
+      outputTensor,
+      originalWidth,
+      originalHeight,
+    );
+
+    // 5. Generate Industrial Summary
+    const industrialSummary: IndustrialSummary = {};
+    // Initialize with 0 for all known classes
+    Object.values(MPOB_CLASSES).forEach((cls) => {
+      industrialSummary[cls] = 0;
+    });
+
+    detections.forEach((det) => {
+      // Re-verify health alert flag just in case
+      det.is_health_alert = HEALTH_ALERT_CLASSES.includes(det.class);
+      industrialSummary[det.class] = (industrialSummary[det.class] || 0) + 1;
+    });
+
+    console.log('📊 Industrial Summary:', industrialSummary);
+
+    const processingMs = performance.now() - processingStart;
+    const archiveId = `palm_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
+
+    // 6. Persistence to SQLite
+    const history = new History();
+    history.archive_id = archiveId;
+    history.filename = originalFilename;
+    history.total_count = detections.length;
+    history.industrial_summary = industrialSummary;
+    
+    // Ensure detections is an array of objects, not a leaked JSON string
+    history.detections = Array.isArray(detections) ? detections : [];
+    
+    history.inference_ms = parseFloat(inferenceMs.toFixed(2));
+    history.processing_ms = parseFloat(processingMs.toFixed(2));
+    
+    await this.historyRepository.save(history);
+
+    return {
+      status: 'success',
+      current_threshold: 0.25,
+      total_count: detections.length,
+      industrial_summary: industrialSummary,
+      detections: history.detections,
+      inference_ms: history.inference_ms,
+      processing_ms: history.processing_ms,
+      archive_id: archiveId,
+    };
+  }
+
+  async getHistory(): Promise<History[]> {
+    // Return objects directly; TypeORM simple-json handles parsing
+    return this.historyRepository.find({
+      order: {
+        created_at: 'DESC',
+      },
+      take: 50,
+    });
+  }
+}

+ 110 - 0
src/palm-oil/providers/scanner.provider.ts

@@ -0,0 +1,110 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import * as onnx from 'onnxruntime-node';
+import * as sharp from 'sharp';
+import * as path from 'path';
+import { MPOB_CLASSES, HEALTH_ALERT_CLASSES } from '../constants/mpob-standards';
+import { DetectionResult } from '../interfaces/palm-analysis.interface';
+
+@Injectable()
+export class ScannerProvider implements OnModuleInit {
+  private session: onnx.InferenceSession;
+  private readonly modelPath = path.join(process.cwd(), 'best.onnx');
+
+  async onModuleInit() {
+    try {
+      this.session = await onnx.InferenceSession.create(this.modelPath);
+      console.log('✅ ONNX Inference Session initialized from:', this.modelPath);
+    } catch (error) {
+      console.error('❌ Failed to initialize ONNX Inference Session:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * Preprocesses the image buffer: resize to 640x640, transpose HWC to CHW, and normalize.
+   */
+  async preprocess(imageBuffer: Buffer): Promise<onnx.Tensor> {
+    // Proper Sharp RGB extraction
+    const resized = await sharp(imageBuffer)
+      .resize(640, 640, { fit: 'fill' })
+      .removeAlpha()
+      .raw()
+      .toBuffer({ resolveWithObject: true });
+
+    const { width, height, channels } = resized.info;
+    const pixels = resized.data; // Uint8Array [R, G, B, R, G, B...]
+
+    const imageSize = width * height;
+    const floatData = new Float32Array(3 * imageSize);
+
+    // HWC to CHW Transposition
+    // pixels: [R1, G1, B1, R2, G2, B2...]
+    // floatData: [R1, R2, ..., G1, G2, ..., B1, B2, ...]
+    for (let i = 0; i < imageSize; i++) {
+      floatData[i] = pixels[i * 3] / 255.0; // R
+      floatData[i + imageSize] = pixels[i * 3 + 1] / 255.0; // G
+      floatData[i + 2 * imageSize] = pixels[i * 3 + 2] / 255.0; // B
+    }
+
+    return new onnx.Tensor('float32', floatData, [1, 3, 640, 640]);
+  }
+
+  /**
+   * Executes the ONNX session with the preprocessed tensor.
+   */
+  async inference(tensor: onnx.Tensor): Promise<onnx.Tensor> {
+    const inputs = { images: tensor };
+    const outputs = await this.session.run(inputs);
+    
+    // The model typically returns the output under a generic name like 'output0' or 'outputs'
+    // We'll take the first output key available
+    const outputKey = Object.keys(outputs)[0];
+    return outputs[outputKey];
+  }
+
+  /**
+   * Post-processes the model output: filtering, scaling, and mapping to MPOB standards.
+   */
+  async postprocess(
+    outputTensor: onnx.Tensor,
+    originalWidth: number,
+    originalHeight: number,
+    threshold: number = 0.25,
+  ): Promise<DetectionResult[]> {
+    const data = outputTensor.data as Float32Array;
+    // Expected shape: [1, 300, 6]
+    // Each candidate: [x1, y1, x2, y2, confidence, class_index]
+    
+    const results: DetectionResult[] = [];
+    const numCandidates = outputTensor.dims[1];
+
+    for (let i = 0; i < numCandidates; i++) {
+      const offset = i * 6;
+      const x1 = data[offset];
+      const y1 = data[offset + 1];
+      const x2 = data[offset + 2];
+      const y2 = data[offset + 3];
+      const confidence = data[offset + 4];
+      const classIndex = data[offset + 5];
+
+      if (confidence >= threshold) {
+        const className = MPOB_CLASSES[Math.round(classIndex)] || 'Unknown';
+        results.push({
+          bunch_id: results.length + 1,
+          class: className,
+          confidence: parseFloat(confidence.toFixed(4)),
+          is_health_alert: HEALTH_ALERT_CLASSES.includes(className),
+          // Normalize by dividing by 640 (the model input size)
+          box: [
+            x1 / 640, 
+            y1 / 640, 
+            x2 / 640, 
+            y2 / 640
+          ], 
+        });
+      }
+    }
+
+    return results;
+  }
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.