feat: Improve face recognition accuracy and speed by adjusting server tolerance, filtering matches, and updating real-time recognition interval.

This commit is contained in:
Suherdy Yacob 2026-01-02 16:26:31 +07:00
parent 9aedd23c97
commit b537847506
27 changed files with 97 additions and 20 deletions

0
.gitignore vendored Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
__init__.py Normal file → Executable file
View File

0
__manifest__.py Normal file → Executable file
View File

View File

@ -0,0 +1,61 @@
# AI Face Recognition Server
A Flask-based standalone server for face recognition, designed for integration with Odoo Point of Sale (POS).
## Features
- **Real-time Recognition**: Processes images and returns matches with metadata (ID, Name, Probability).
- **Face Training**: API endpoint to register new faces from base64 images.
- **Production Ready**: Supports Gunicorn for stability and concurrency.
- **Optimized for POS**: Default 60% match threshold handles varied lighting conditions well.
## Prerequisites
- Python 3.10+
- System libraries for `dlib` (cmake, g++, etc.)
- Virtual environment (recommended)
## Installation
1. **Activate the Virtual Environment**:
```bash
source .venv/bin/activate
```
2. **Install Dependencies**:
```bash
pip install -r requirements.txt
```
## Running the Server
### Production (Recommended)
Run using Gunicorn for better performance and stability:
```bash
.venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 app:app --timeout 120
```
### Development
```bash
python app.py
```
## API Endpoints
### 1. Recognize Face
- **Endpoint**: `POST /recognize`
- **Body**: `{ "image": "base64_string_without_header" }`
- **Response**: List of matches with `id`, `name`, and `probability`.
### 2. Train Face
- **Endpoint**: `POST /train`
- **Body**: `{ "partner_id": 123, "name": "John Doe", "images": ["base64_1", "base64_2"] }`
- **Description**: Saves images to the `faces/` directory and updates the in-memory model.
## Matching Logic
- **Tolerance**: `0.4` (Distance)
- **Probability**: `1.0 - distance` (Matches >= 60% are returned)
- **Diagnostics**: Server logs the exact distance and probability to the terminal for every request.
## Directory Structure
- `app.py`: Main Flask application.
- `faces/`: Directory where trained face images are stored.
- `requirements.txt`: Python dependencies.

22
ai_face_recognition_server/app.py Normal file → Executable file
View File

@ -82,8 +82,8 @@ def recognize_face():
matches_list = []
for face_encoding in face_encodings:
# See if the face is a match for the known face(s)
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
# Use threshold of 0.4 for slightly less strict matching (around 60% match)
matches = face_recognition.compare_faces(known_face_encodings, face_encoding, tolerance=0.4)
name = "Unknown"
p_id = 0
probability = 0.0
@ -96,15 +96,18 @@ def recognize_face():
name = known_face_names[best_match_index]
p_id = known_face_ids[best_match_index]
# Simple probability estimation based on distance (lower distance = higher prob)
# Distance 0.0 -> 100%, Distance 0.6 -> ~0% (threshold)
# Distance 0.0 -> 100%, Distance 0.3 -> 70%
distance = face_distances[best_match_index]
probability = max(0, 1.0 - distance)
print(f"Best match for current face: {name} (ID: {p_id}) with distance {distance:.3f} (Prob: {probability:.3f})")
matches_list.append({
'id': p_id,
'name': name,
'probability': probability
})
if probability >= 0.6:
matches_list.append({
'id': p_id,
'name': name,
'probability': probability
})
# Sort by probability
matches_list.sort(key=lambda x: x['probability'], reverse=True)
@ -165,6 +168,7 @@ def train_face():
return jsonify({'message': f'Processed {processed_count} images for {name}'})
load_known_faces()
if __name__ == '__main__':
load_known_faces()
app.run(host='0.0.0.0', port=5000)

View File

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

2
ai_face_recognition_server/requirements.txt Normal file → Executable file
View File

@ -3,3 +3,5 @@ flask-cors
face_recognition
numpy
opencv-python
setuptools
gunicorn

0
models/__init__.py Normal file → Executable file
View File

0
models/pos_config.py Normal file → Executable file
View File

0
models/res_config_settings.py Normal file → Executable file
View File

0
models/res_partner.py Normal file → Executable file
View File

0
static/src/css/face_recognition.css Normal file → Executable file
View File

28
static/src/js/face_recognition_sidebar.js Normal file → Executable file
View File

@ -59,17 +59,17 @@ export class FaceRecognitionSidebar extends Component {
startRealTimeRecognition() {
console.log("startRealTimeRecognition called");
console.log("Server URL:", this.pos.config.pos_face_rec_server_url);
if (!this.pos.config.pos_face_rec_server_url) {
console.error("Face Recognition Server URL is not configured. Please configure it in POS Settings.");
return;
}
// Capture frame every 5 seconds
// Capture frame every 3 seconds
this.recognitionInterval = setInterval(() => {
console.log("Recognition interval tick");
this.captureAndSendFrame();
}, 5000);
}, 3000);
}
async captureAndSendFrame() {
@ -78,7 +78,7 @@ export class FaceRecognitionSidebar extends Component {
console.error("Video element not found");
return;
}
if (!this.pos.config.pos_face_rec_server_url) {
console.error("Face Recognition Server URL is not configured");
return;
@ -103,14 +103,19 @@ export class FaceRecognitionSidebar extends Component {
if (response.ok) {
const result = await response.json();
if (result.matches && result.matches.length > 0) {
// Filter matches (ensure only >= 60% probability)
const filteredMatches = result.matches.filter(match => match.probability >= 0.6);
// Enrich matches with partner phone numbers
this.state.matches = result.matches.map(match => {
this.state.matches = filteredMatches.map(match => {
const partner = this.pos.db.get_partner_by_id(match.id);
return {
...match,
phone: partner ? partner.phone || partner.mobile : null
};
});
} else {
this.state.matches = [];
}
}
} catch (err) {
@ -119,13 +124,18 @@ export class FaceRecognitionSidebar extends Component {
}
async selectCustomer(match) {
console.log("selectCustomer called with match:", match);
const partner = this.pos.db.get_partner_by_id(match.id);
console.log("Partner found in DB:", partner);
if (partner) {
this.state.selectedCustomer = partner;
this.pos.get_order().set_partner(partner);
console.log("Partner set to current order:", this.pos.get_order().get_partner());
// Fetch details
await this.fetchCustomerDetails(partner.id);
} else {
console.error("Partner not found for ID:", match.id);
}
}
@ -136,7 +146,7 @@ export class FaceRecognitionSidebar extends Component {
['name', 'date_order', 'amount_total', 'state', 'lines'],
{ limit: 2, order: "date_order desc" }
);
// Fetch order lines for each order
for (let order of orders) {
if (order.lines && order.lines.length > 0) {
@ -154,7 +164,7 @@ export class FaceRecognitionSidebar extends Component {
order.lines = [];
}
}
this.state.lastOrders = orders;
// Fetch top 3 products
@ -163,7 +173,7 @@ export class FaceRecognitionSidebar extends Component {
console.log("Top products received:", topProducts);
console.log("Type:", typeof topProducts);
console.log("First product:", topProducts && topProducts[0]);
// Ensure it's an array
if (Array.isArray(topProducts)) {
this.state.topProducts = topProducts;

0
static/src/js/models.js Normal file → Executable file
View File

0
static/src/js/partner_details_edit.js Normal file → Executable file
View File

4
static/src/xml/face_recognition_screens.xml Normal file → Executable file
View File

@ -22,7 +22,7 @@
<h4>Matches:</h4>
<ul class="match-list">
<t t-foreach="state.matches" t-as="match" t-key="match.id">
<li t-on-click="() => this.selectCustomer(match)" class="match-item">
<li t-on-click="() => this.selectCustomer(match)" class="match-item" style="cursor: pointer; background: rgba(0,0,0,0.05); margin-bottom: 5px; padding: 5px;">
<div class="match-info">
<div>
<span class="match-name"><t t-esc="match.name"/></span>
@ -84,7 +84,7 @@
<ul t-if="state.topProducts and state.topProducts.length > 0">
<t t-foreach="state.topProducts" t-as="prod" t-key="prod_index">
<li>
<t t-out="prod['name']['en_US']"/> (Qty: <t t-out="Math.round(prod['qty'])"/>)
<t t-out="prod['name']['en_US'] || prod['name']"/> (Qty: <t t-out="Math.round(prod['qty'])"/>)
</li>
</t>
</ul>

0
static/src/xml/partner_details_edit.xml Normal file → Executable file
View File

0
views/pos_config_views.xml Normal file → Executable file
View File

0
views/res_config_settings_views.xml Normal file → Executable file
View File

0
views/res_partner_views.xml Normal file → Executable file
View File