Преглед изворни кода

feat: implement palm oil ripeness detection service with ONNX inference and SQLite history persistence

Dr-Swopt пре 1 недеља
родитељ
комит
99147dc0cc
5 измењених фајлова са 214 додато и 71 уклоњено
  1. 2 0
      .gitignore
  2. 172 69
      README.md
  3. 3 0
      src/palm-oil/entities/history.entity.ts
  4. 16 1
      src/palm-oil/palm-oil.controller.ts
  5. 21 1
      src/palm-oil/palm-oil.service.ts

+ 2 - 0
.gitignore

@@ -27,3 +27,5 @@ yarn-error.log*
 # OS
 # OS
 .DS_Store
 .DS_Store
 Thumbs.db
 Thumbs.db
+/archive
+palm_history.db

+ 172 - 69
README.md

@@ -1,98 +1,201 @@
-<p align="center">
-  <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
-</p>
-
-[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
-[circleci-url]: https://circleci.com/gh/nestjs/nest
-
-  <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
-    <p align="center">
-<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
-<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
-<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
-<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
-<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
-<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
-<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
-  <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
-    <a href="https://opencollective.com/nest#sponsor"  target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
-  <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
-</p>
-  <!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
-  [![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
-
-## Description
-
-[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
-
-## Project setup
+# 🌴 Palm Oil Ripeness Detection Service
+
+A **NestJS** backend API that uses an **ONNX-based YOLOv8 model** to perform real-time ripeness classification of oil palm fresh fruit bunches (FFB). Detection results are mapped against **MPOB (Malaysian Palm Oil Board) grading standards**, persisted to a local SQLite database, and served via a REST API.
+
+---
+
+## 🚀 Features
+
+- **AI Inference via ONNX Runtime** — Runs a custom-trained YOLOv8 model (`best.onnx`) using `onnxruntime-node` for zero-dependency, high-performance server-side inference.
+- **MPOB-Standard Classification** — Detects and classifies palm oil bunches into 6 industry-standard grades:
+  | Class | Description |
+  |---|---|
+  | `Ripe` | Optimal harvest quality |
+  | `Underripe` | Harvested too early |
+  | `Unripe` | Not yet ready |
+  | `Overripe` | Past optimal harvest window |
+  | `Abnormal` | ⚠️ Health alert |
+  | `Empty_Bunch` | ⚠️ Health alert |
+- **Industrial Summary** — Each analysis response includes a per-class count summary for field reporting.
+- **Health Alert Flagging** — Detections with `Abnormal` or `Empty_Bunch` classes are automatically flagged with `is_health_alert: true`.
+- **History Persistence** — All scans are saved to a local **SQLite** database via TypeORM, with the last 50 records retrievable via the history endpoint.
+- **CORS Enabled** — Ready for integration with any frontend (Angular, React, etc.).
+- **Image Pass-Through** — The original image is returned as a Base64 data URI in the response for frontend canvas rendering.
+
+---
+
+## 🏗️ Architecture Overview
 
 
-```bash
-$ npm install
+```
+src/
+├── main.ts                          # Bootstrap (port 3000, CORS enabled)
+├── app.module.ts                    # Root module
+└── palm-oil/
+    ├── palm-oil.controller.ts       # REST endpoints
+    ├── palm-oil.service.ts          # Orchestration logic & SQLite persistence
+    ├── palm-oil.module.ts           # Feature module
+    ├── providers/
+    │   └── scanner.provider.ts      # ONNX inference pipeline (preprocess → infer → postprocess)
+    ├── entities/
+    │   └── history.entity.ts        # TypeORM entity for scan history
+    ├── interfaces/
+    │   └── palm-analysis.interface.ts  # TypeScript types for API response
+    └── constants/
+        └── mpob-standards.ts        # MPOB class map, grade colors, and health alert list
 ```
 ```
 
 
-## Compile and run the project
+### Inference Pipeline (`ScannerProvider`)
 
 
-```bash
-# development
-$ npm run start
+1. **Preprocess** — Resize image to `640×640` using `sharp`, strip alpha, extract raw pixels, and transpose from `HWC → CHW` layout, normalizing to `[0.0, 1.0]`.
+2. **Inference** — Feed the `[1, 3, 640, 640]` float tensor into the ONNX session. Input key: `images`.
+3. **Postprocess** — Parse the `[1, N, 6]` output tensor (`x1, y1, x2, y2, confidence, class_index`). Filter by a default confidence threshold of `0.25`. Scale normalized bounding box coordinates to the original image pixel dimensions.
+
+---
+
+## 📋 Prerequisites
+
+- **Node.js** `>=18`
+- **npm** `>=9`
+- The ONNX model file `best.onnx` must be placed in the **project root directory**.
+
+---
 
 
-# watch mode
-$ npm run start:dev
+## ⚙️ Project Setup
 
 
-# production mode
-$ npm run start:prod
+```bash
+npm install
 ```
 ```
 
 
-## Run tests
+---
+
+## ▶️ Running the Service
 
 
 ```bash
 ```bash
-# unit tests
-$ npm run test
+# Development (single run)
+npm run start
 
 
-# e2e tests
-$ npm run test:e2e
+# Development (watch mode — auto-restarts on file changes)
+npm run start:dev
+
+# Production
+npm run start:prod
+```
 
 
-# test coverage
-$ npm run test:cov
+The server starts on **`http://localhost:3000`** by default. Set the `PORT` environment variable to override.
+
+---
+
+## 📡 API Endpoints
+
+### `POST /palm-oil/analyze`
+
+Analyzes an uploaded image for palm oil ripeness.
+
+**Request:** `multipart/form-data`
+
+| Field | Type | Description |
+|---|---|---|
+| `image` | `File` | The palm oil bunch image to analyze |
+
+**Response:** `application/json`
+
+```json
+{
+  "status": "success",
+  "current_threshold": 0.25,
+  "total_count": 4,
+  "industrial_summary": {
+    "Empty_Bunch": 0,
+    "Underripe": 1,
+    "Abnormal": 0,
+    "Ripe": 2,
+    "Unripe": 0,
+    "Overripe": 1
+  },
+  "detections": [
+    {
+      "bunch_id": 1,
+      "class": "Ripe",
+      "confidence": 0.9312,
+      "is_health_alert": false,
+      "box": [120.5, 88.3, 450.2, 390.1]
+    }
+  ],
+  "image_data": "data:image/jpeg;base64,...",
+  "inference_ms": 42.15,
+  "processing_ms": 115.30,
+  "archive_id": "palm_1712345678901_456"
+}
 ```
 ```
 
 
-## Deployment
+---
+
+### `GET /palm-oil/history`
+
+Returns the last 50 scan records from the SQLite database, ordered by most recent first.
+
+**Response:** `application/json` — Array of `History` entities.
 
 
-When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
+---
 
 
-If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
+## 🧪 Running Tests
 
 
 ```bash
 ```bash
-$ npm install -g @nestjs/mau
-$ mau deploy
+# Unit tests
+npm run test
+
+# Unit tests (watch mode)
+npm run test:watch
+
+# End-to-end tests
+npm run test:e2e
+
+# Test coverage report
+npm run test:cov
 ```
 ```
 
 
-With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
+---
+
+## 🗄️ Database
+
+Scan history is persisted to a local **SQLite** file (`palm_history.db`) in the project root, managed by **TypeORM** with `synchronize: true`. No external database setup is required.
+
+Each history record stores:
+- `archive_id` — Unique scan identifier
+- `filename` — Original uploaded file name
+- `total_count` — Number of detections
+- `industrial_summary` — Per-class count (JSON)
+- `detections` — Full detection array with bounding boxes (JSON)
+- `inference_ms` / `processing_ms` — Performance timings
+- `created_at` — Auto-generated timestamp
 
 
-## Resources
+---
 
 
-Check out a few resources that may come in handy when working with NestJS:
+## 🗂️ Key Files
 
 
-- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
-- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
-- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
-- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
-- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
-- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
-- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
-- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
+| File | Purpose |
+|---|---|
+| `best.onnx` | YOLOv8 ONNX inference model (must be in project root) |
+| `palm_history.db` | SQLite scan history database (auto-created) |
+| `src/palm-oil/constants/mpob-standards.ts` | MPOB class definitions, colors, and health alert flags |
+| `src/palm-oil/providers/scanner.provider.ts` | Core AI inference pipeline |
+| `src/palm-oil/palm-oil.service.ts` | Business logic, summary generation, persistence |
+| `src/palm-oil/palm-oil.controller.ts` | REST API layer |
 
 
-## Support
+---
 
 
-Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
+## 📦 Core Dependencies
 
 
-## Stay in touch
+| Package | Purpose |
+|---|---|
+| `@nestjs/core` | NestJS framework |
+| `onnxruntime-node` | ONNX model inference |
+| `sharp` | High-performance image preprocessing |
+| `typeorm` + `sqlite3` | Database ORM and SQLite driver |
+| `class-validator` | DTO validation |
 
 
-- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
-- Website - [https://nestjs.com](https://nestjs.com/)
-- Twitter - [@nestframework](https://twitter.com/nestframework)
+---
 
 
-## License
+## 📄 License
 
 
-Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+UNLICENSED — Private project.

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

@@ -26,6 +26,9 @@ export class History {
   @Column({ type: 'float' })
   @Column({ type: 'float' })
   processing_ms: number;
   processing_ms: number;
 
 
+  @Column({ nullable: true })
+  image_path: string;
+
   @CreateDateColumn()
   @CreateDateColumn()
   created_at: Date;
   created_at: Date;
 }
 }

+ 16 - 1
src/palm-oil/palm-oil.controller.ts

@@ -1,7 +1,9 @@
-import { Controller, Post, Get, UseInterceptors, UploadedFile } from '@nestjs/common';
+import { Controller, Post, Get, UseInterceptors, UploadedFile, Res, Param } from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
 import { FileInterceptor } from '@nestjs/platform-express';
 import { PalmOilService } from './palm-oil.service';
 import { PalmOilService } from './palm-oil.service';
 import { AnalysisResponse } from './interfaces/palm-analysis.interface';
 import { AnalysisResponse } from './interfaces/palm-analysis.interface';
+import { Response } from 'express';
+import * as fs from 'fs';
 
 
 @Controller('palm-oil')
 @Controller('palm-oil')
 export class PalmOilController {
 export class PalmOilController {
@@ -23,4 +25,17 @@ export class PalmOilController {
   async getHistory() {
   async getHistory() {
     return this.palmOilService.getHistory();
     return this.palmOilService.getHistory();
   }
   }
+
+  @Get('archive/:id')
+  async getArchivedImage(@Param('id') id: string, @Res() res: Response) {
+    const record = await this.palmOilService.getRecordByArchiveId(id);
+    if (!record || !record.image_path) {
+      return res.status(404).send('Image not found');
+    }
+
+    // Set the correct header and stream the file
+    res.setHeader('Content-Type', 'image/jpeg');
+    const fileStream = fs.createReadStream(record.image_path);
+    fileStream.pipe(res);
+  }
 }
 }

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

@@ -6,15 +6,24 @@ import * as sharp from 'sharp';
 import { performance } from 'perf_hooks';
 import { performance } from 'perf_hooks';
 import { AnalysisResponse, IndustrialSummary } from './interfaces/palm-analysis.interface';
 import { AnalysisResponse, IndustrialSummary } from './interfaces/palm-analysis.interface';
 import { History } from './entities/history.entity';
 import { History } from './entities/history.entity';
+import * as fs from 'fs';
+import * as path from 'path';
+
 import { MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './constants/mpob-standards';
 import { MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './constants/mpob-standards';
 
 
 @Injectable()
 @Injectable()
 export class PalmOilService {
 export class PalmOilService {
+  private readonly ARCHIVE_DIR = path.join(process.cwd(), 'archive');
+
   constructor(
   constructor(
     private readonly scanner: ScannerProvider,
     private readonly scanner: ScannerProvider,
     @InjectRepository(History)
     @InjectRepository(History)
     private readonly historyRepository: Repository<History>,
     private readonly historyRepository: Repository<History>,
-  ) { }
+  ) {
+    if (!fs.existsSync(this.ARCHIVE_DIR)) {
+      fs.mkdirSync(this.ARCHIVE_DIR, { recursive: true });
+    }
+  }
 
 
   async analyzeImage(imageBuffer: Buffer, originalFilename: string = 'unknown.jpg'): Promise<AnalysisResponse> {
   async analyzeImage(imageBuffer: Buffer, originalFilename: string = 'unknown.jpg'): Promise<AnalysisResponse> {
     const processingStart = performance.now();
     const processingStart = performance.now();
@@ -57,10 +66,17 @@ export class PalmOilService {
     const processingMs = performance.now() - processingStart;
     const processingMs = performance.now() - processingStart;
     const archiveId = `palm_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
     const archiveId = `palm_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
 
 
+    const fileName = `${archiveId}.jpg`;
+    const fullPath = path.join(this.ARCHIVE_DIR, fileName);
+
+    // Physical Heavy Lifting: Save the image to disk
+    fs.writeFileSync(fullPath, imageBuffer);
+
     // 6. Persistence to SQLite
     // 6. Persistence to SQLite
     const history = new History();
     const history = new History();
     history.archive_id = archiveId;
     history.archive_id = archiveId;
     history.filename = originalFilename;
     history.filename = originalFilename;
+    history.image_path = fullPath; // Store the reference
     history.total_count = detections.length;
     history.total_count = detections.length;
     history.industrial_summary = industrialSummary;
     history.industrial_summary = industrialSummary;
 
 
@@ -94,4 +110,8 @@ export class PalmOilService {
       take: 50,
       take: 50,
     });
     });
   }
   }
+
+  async getRecordByArchiveId(archiveId: string): Promise<History | null> {
+    return this.historyRepository.findOne({ where: { archive_id: archiveId } });
+  }
 }
 }