first commit
This commit is contained in:
commit
f574cc297b
152
.gitignore
vendored
Normal file
152
.gitignore
vendored
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node modules (if using any JS tools)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
322
README.md
Normal file
322
README.md
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
# POS Face Recognition
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This Odoo module extends the Point of Sale (POS) system with AI-powered face recognition capabilities. It enables automatic customer identification through facial recognition, providing staff with instant access to customer information, purchase history, and personalized recommendations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎯 Core Functionality
|
||||||
|
|
||||||
|
- **Real-time Face Recognition**: Automatically identifies customers using webcam in the POS interface
|
||||||
|
- **Customer Training**: Capture and store up to 3 face images per customer for accurate recognition
|
||||||
|
- **Automatic Sync**: Face images are automatically synchronized with the AI server
|
||||||
|
- **Confidence Scoring**: Displays match probability for each recognized customer
|
||||||
|
|
||||||
|
### 📊 Customer Insights
|
||||||
|
|
||||||
|
When a customer is recognized, the sidebar displays:
|
||||||
|
|
||||||
|
- **Customer Information**: Name and contact details
|
||||||
|
- **Last 2 Orders**: Complete order history including:
|
||||||
|
- Order number and date
|
||||||
|
- Total amount
|
||||||
|
- Order status (with color-coded badges)
|
||||||
|
- Complete product list with quantities and prices
|
||||||
|
- **Top 3 Products**: Most frequently purchased items by the customer
|
||||||
|
|
||||||
|
### 🎨 User Interface
|
||||||
|
|
||||||
|
- **Sidebar Integration**: Non-intrusive sidebar in the POS product screen
|
||||||
|
- **Live Camera Feed**: Real-time video preview for face recognition
|
||||||
|
- **Match List**: Shows all potential customer matches with confidence scores
|
||||||
|
- **Detailed Order Cards**: Beautifully styled order history with hover effects
|
||||||
|
- **Responsive Design**: Optimized for POS touchscreen interfaces
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Odoo Dependencies
|
||||||
|
|
||||||
|
- `point_of_sale`
|
||||||
|
- `contacts`
|
||||||
|
|
||||||
|
### External Dependencies
|
||||||
|
|
||||||
|
- **AI Face Recognition Server**: A separate Python Flask server that handles face recognition
|
||||||
|
- Repository: `ai_face_recognition_server` (included in workspace)
|
||||||
|
- Required Python packages: `flask`, `face_recognition`, `numpy`, `opencv-python`
|
||||||
|
|
||||||
|
### Hardware Requirements
|
||||||
|
|
||||||
|
- Webcam or camera device
|
||||||
|
- Sufficient lighting for face recognition
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Install the Odoo Module
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the module to your Odoo addons directory
|
||||||
|
cp -r pos_face_recognition /path/to/odoo/addons/
|
||||||
|
|
||||||
|
# Update the addons list in Odoo
|
||||||
|
# Navigate to Apps > Update Apps List
|
||||||
|
|
||||||
|
# Search for "POS Face Recognition" and install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Setup the AI Face Recognition Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the AI server directory
|
||||||
|
cd ai_face_recognition_server
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will run on `http://localhost:5000` by default.
|
||||||
|
|
||||||
|
### 3. Configure the Module
|
||||||
|
|
||||||
|
1. Go to **Point of Sale > Configuration > Point of Sale**
|
||||||
|
2. Select your POS configuration
|
||||||
|
3. In the **Face Recognition** section, set the **Face Recognition Server URL**:
|
||||||
|
```
|
||||||
|
http://localhost:5000
|
||||||
|
```
|
||||||
|
4. Save the configuration
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Training Customer Faces
|
||||||
|
|
||||||
|
1. Navigate to **Contacts**
|
||||||
|
2. Open a customer record
|
||||||
|
3. In the **Face Recognition** tab, you'll find three image fields:
|
||||||
|
- **Face Image 1**
|
||||||
|
- **Face Image 2**
|
||||||
|
- **Face Image 3**
|
||||||
|
4. Upload clear, front-facing photos of the customer
|
||||||
|
5. Save the record - images are automatically synced to the AI server
|
||||||
|
|
||||||
|
**Best Practices for Training Images:**
|
||||||
|
- Use well-lit, clear photos
|
||||||
|
- Capture different angles and expressions
|
||||||
|
- Ensure the face is clearly visible
|
||||||
|
- Avoid sunglasses or face coverings
|
||||||
|
- Use recent photos
|
||||||
|
|
||||||
|
### Using Face Recognition in POS
|
||||||
|
|
||||||
|
1. Open the POS session
|
||||||
|
2. The face recognition sidebar appears on the right side
|
||||||
|
3. The camera automatically starts and begins scanning for faces
|
||||||
|
4. When a customer is recognized:
|
||||||
|
- Their name appears in the match list with a confidence score
|
||||||
|
- Click on the match to select the customer
|
||||||
|
- View their order history and top products
|
||||||
|
- The customer is automatically set for the current order
|
||||||
|
|
||||||
|
### Understanding the Display
|
||||||
|
|
||||||
|
**Match List:**
|
||||||
|
- Shows all potential matches with confidence percentage
|
||||||
|
- Higher percentage = more confident match
|
||||||
|
- Click any match to select that customer
|
||||||
|
|
||||||
|
**Order History:**
|
||||||
|
- Displays the last 2 orders with complete details
|
||||||
|
- Color-coded status badges:
|
||||||
|
- 🟢 Green: Paid/Done
|
||||||
|
- 🔵 Blue: Invoiced
|
||||||
|
- Shows all products ordered with quantities and prices
|
||||||
|
|
||||||
|
**Top Products:**
|
||||||
|
- Highlights the 3 most frequently purchased items
|
||||||
|
- Helps staff make personalized recommendations
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### POS Configuration
|
||||||
|
|
||||||
|
**Settings > Point of Sale > Configuration > Point of Sale**
|
||||||
|
|
||||||
|
- `pos_face_rec_server_url`: URL of the AI Face Recognition Server
|
||||||
|
|
||||||
|
### Partner Configuration
|
||||||
|
|
||||||
|
**Contacts > Customer**
|
||||||
|
|
||||||
|
- `image_face_1`: First training image
|
||||||
|
- `image_face_2`: Second training image
|
||||||
|
- `image_face_3`: Third training image
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pos_face_recognition/
|
||||||
|
├── __init__.py
|
||||||
|
├── __manifest__.py
|
||||||
|
├── README.md
|
||||||
|
├── models/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── pos_config.py # POS configuration extension
|
||||||
|
│ ├── res_partner.py # Customer model with face images
|
||||||
|
│ └── res_config_settings.py # Settings configuration
|
||||||
|
├── views/
|
||||||
|
│ ├── pos_config_views.xml
|
||||||
|
│ ├── res_partner_views.xml
|
||||||
|
│ └── res_config_settings_views.xml
|
||||||
|
└── static/
|
||||||
|
└── src/
|
||||||
|
├── css/
|
||||||
|
│ └── face_recognition.css
|
||||||
|
├── js/
|
||||||
|
│ ├── face_recognition_sidebar.js
|
||||||
|
│ ├── models.js
|
||||||
|
│ └── partner_details_edit.js
|
||||||
|
└── xml/
|
||||||
|
├── face_recognition_screens.xml
|
||||||
|
└── partner_details_edit.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
The module communicates with the AI server using these endpoints:
|
||||||
|
|
||||||
|
**POST /train**
|
||||||
|
- Trains the AI model with customer face images
|
||||||
|
- Payload: `{ partner_id, name, images: [base64_image1, base64_image2, base64_image3] }`
|
||||||
|
|
||||||
|
**POST /recognize**
|
||||||
|
- Recognizes faces in the provided image
|
||||||
|
- Payload: `{ image: base64_image }`
|
||||||
|
- Response: `{ matches: [{ id, name, probability }] }`
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Training Phase:**
|
||||||
|
- Customer face images uploaded in Odoo
|
||||||
|
- Images automatically synced to AI server via `/train` endpoint
|
||||||
|
- AI server trains face recognition model
|
||||||
|
|
||||||
|
2. **Recognition Phase:**
|
||||||
|
- POS camera captures frames every 5 seconds
|
||||||
|
- Frame sent to AI server via `/recognize` endpoint
|
||||||
|
- Server returns matching customers with confidence scores
|
||||||
|
- POS displays matches and fetches order history
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**res.partner (extended)**
|
||||||
|
```python
|
||||||
|
image_face_1 = fields.Binary("Face Image 1", attachment=True)
|
||||||
|
image_face_2 = fields.Binary("Face Image 2", attachment=True)
|
||||||
|
image_face_3 = fields.Binary("Face Image 3", attachment=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**pos.config (extended)**
|
||||||
|
```python
|
||||||
|
pos_face_rec_server_url = fields.Char("Face Recognition Server URL")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Camera Not Working
|
||||||
|
|
||||||
|
- **Check browser permissions**: Ensure the browser has camera access
|
||||||
|
- **HTTPS requirement**: Some browsers require HTTPS for camera access
|
||||||
|
- **Check console**: Open browser developer tools for error messages
|
||||||
|
|
||||||
|
### No Matches Found
|
||||||
|
|
||||||
|
- **Verify server connection**: Check if the AI server is running
|
||||||
|
- **Check server URL**: Ensure the URL in POS config is correct
|
||||||
|
- **Training data**: Verify customer has face images uploaded
|
||||||
|
- **Lighting**: Ensure adequate lighting for face detection
|
||||||
|
|
||||||
|
### Images Not Syncing
|
||||||
|
|
||||||
|
- **Check server URL**: Verify the Face Recognition Server URL is configured
|
||||||
|
- **Server logs**: Check AI server logs for errors
|
||||||
|
- **Network connectivity**: Ensure Odoo can reach the AI server
|
||||||
|
- **Image format**: Ensure images are in supported formats (JPEG, PNG)
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
- **Recognition interval**: Default is 5 seconds, can be adjusted in code
|
||||||
|
- **Image quality**: Lower resolution images process faster
|
||||||
|
- **Server resources**: Ensure AI server has adequate CPU/RAM
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Extending the Module
|
||||||
|
|
||||||
|
**Add more customer insights:**
|
||||||
|
```python
|
||||||
|
# In res_partner.py
|
||||||
|
def get_customer_stats(self):
|
||||||
|
self.ensure_one()
|
||||||
|
# Add custom logic
|
||||||
|
return {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize recognition interval:**
|
||||||
|
```javascript
|
||||||
|
// In face_recognition_sidebar.js
|
||||||
|
this.recognitionInterval = setInterval(() => {
|
||||||
|
this.captureAndSendFrame();
|
||||||
|
}, 3000); // Change from 5000 to 3000 for 3 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add more order history:**
|
||||||
|
```javascript
|
||||||
|
// In face_recognition_sidebar.js
|
||||||
|
const orders = await this.orm.searchRead("pos.order",
|
||||||
|
[['partner_id', '=', partnerId]],
|
||||||
|
['name', 'date_order', 'amount_total', 'state', 'lines'],
|
||||||
|
{ limit: 5, order: "date_order desc" } // Change from 2 to 5
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security & Privacy
|
||||||
|
|
||||||
|
- Face images are stored securely in Odoo's attachment system
|
||||||
|
- Communication with AI server should use HTTPS in production
|
||||||
|
- Comply with local privacy laws (GDPR, CCPA, etc.)
|
||||||
|
- Obtain customer consent before capturing face images
|
||||||
|
- Implement data retention policies
|
||||||
|
- Provide customers with opt-out options
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues, questions, or contributions:
|
||||||
|
- Check the troubleshooting section
|
||||||
|
- Review server logs for detailed error messages
|
||||||
|
- Ensure all dependencies are properly installed
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 17.0.1.0.0
|
||||||
|
- Initial release
|
||||||
|
- Real-time face recognition in POS
|
||||||
|
- Customer training with 3 images
|
||||||
|
- Last 2 orders display with detailed information
|
||||||
|
- Top 3 products recommendation
|
||||||
|
- Automatic image synchronization
|
||||||
|
- Confidence scoring for matches
|
||||||
|
- Responsive sidebar UI with enhanced styling
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Developed for Odoo 17.0
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
30
__manifest__.py
Normal file
30
__manifest__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
'name': 'POS Face Recognition',
|
||||||
|
'version': '17.0.1.0.0',
|
||||||
|
'category': 'Point of Sale',
|
||||||
|
'summary': 'Face Recognition for POS',
|
||||||
|
'description': """
|
||||||
|
This module extends the Point of Sale to include Face Recognition capabilities.
|
||||||
|
- Capture customer images for training.
|
||||||
|
- Recognize customers in POS using AI.
|
||||||
|
""",
|
||||||
|
'depends': ['point_of_sale', 'contacts'],
|
||||||
|
'data': [
|
||||||
|
'views/res_partner_views.xml',
|
||||||
|
'views/pos_config_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'point_of_sale.assets_prod': [
|
||||||
|
'pos_face_recognition/static/src/xml/face_recognition_screens.xml',
|
||||||
|
'pos_face_recognition/static/src/xml/partner_details_edit.xml',
|
||||||
|
'pos_face_recognition/static/src/js/models.js',
|
||||||
|
'pos_face_recognition/static/src/js/face_recognition_sidebar.js',
|
||||||
|
'pos_face_recognition/static/src/js/partner_details_edit.js',
|
||||||
|
'pos_face_recognition/static/src/css/face_recognition.css',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
170
ai_face_recognition_server/app.py
Normal file
170
ai_face_recognition_server/app.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
import face_recognition
|
||||||
|
import numpy as np
|
||||||
|
import base64
|
||||||
|
import cv2
|
||||||
|
import os
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app) # Enable CORS for all routes
|
||||||
|
|
||||||
|
# In-memory storage for known face encodings and names
|
||||||
|
known_face_encodings = []
|
||||||
|
known_face_names = []
|
||||||
|
known_face_ids = []
|
||||||
|
|
||||||
|
def load_known_faces():
|
||||||
|
"""
|
||||||
|
Load known faces from a directory or database.
|
||||||
|
For this MVP, we'll assume images are stored in a 'faces' directory
|
||||||
|
with filenames like 'partner_id_name.jpg'.
|
||||||
|
In a real production scenario, you'd fetch these from Odoo or a shared storage.
|
||||||
|
"""
|
||||||
|
global known_face_encodings, known_face_names, known_face_ids
|
||||||
|
|
||||||
|
faces_dir = "faces"
|
||||||
|
if not os.path.exists(faces_dir):
|
||||||
|
os.makedirs(faces_dir)
|
||||||
|
print(f"Created {faces_dir} directory. Please add images there.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for filename in os.listdir(faces_dir):
|
||||||
|
if filename.endswith((".jpg", ".png", ".jpeg")):
|
||||||
|
try:
|
||||||
|
# Filename format: ID_Name.jpg
|
||||||
|
name_part = os.path.splitext(filename)[0]
|
||||||
|
parts = name_part.split('_', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
p_id = int(parts[0])
|
||||||
|
name = parts[1]
|
||||||
|
else:
|
||||||
|
p_id = 0
|
||||||
|
name = name_part
|
||||||
|
|
||||||
|
image_path = os.path.join(faces_dir, filename)
|
||||||
|
image = face_recognition.load_image_file(image_path)
|
||||||
|
encodings = face_recognition.face_encodings(image)
|
||||||
|
|
||||||
|
if encodings:
|
||||||
|
known_face_encodings.append(encodings[0])
|
||||||
|
known_face_names.append(name)
|
||||||
|
known_face_ids.append(p_id)
|
||||||
|
print(f"Loaded face for: {name} (ID: {p_id})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading {filename}: {e}")
|
||||||
|
|
||||||
|
@app.route('/recognize', methods=['POST'])
|
||||||
|
def recognize_face():
|
||||||
|
data = request.json
|
||||||
|
if 'image' not in data:
|
||||||
|
return jsonify({'error': 'No image provided'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode base64 image
|
||||||
|
image_data = base64.b64decode(data['image'])
|
||||||
|
nparr = np.frombuffer(image_data, np.uint8)
|
||||||
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
return jsonify({'error': 'Invalid image data'}), 400
|
||||||
|
|
||||||
|
# Convert BGR (OpenCV) to RGB (face_recognition)
|
||||||
|
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
# Ensure the image is in the correct format
|
||||||
|
rgb_frame = np.ascontiguousarray(rgb_frame, dtype=np.uint8)
|
||||||
|
|
||||||
|
# Find all faces in the current frame
|
||||||
|
face_locations = face_recognition.face_locations(rgb_frame)
|
||||||
|
face_encodings = face_recognition.face_encodings(rgb_frame, face_locations)
|
||||||
|
|
||||||
|
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)
|
||||||
|
name = "Unknown"
|
||||||
|
p_id = 0
|
||||||
|
probability = 0.0
|
||||||
|
|
||||||
|
# Or instead, use the known face with the smallest distance to the new face
|
||||||
|
face_distances = face_recognition.face_distance(known_face_encodings, face_encoding)
|
||||||
|
if len(face_distances) > 0:
|
||||||
|
best_match_index = np.argmin(face_distances)
|
||||||
|
if matches[best_match_index]:
|
||||||
|
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 = face_distances[best_match_index]
|
||||||
|
probability = max(0, 1.0 - distance)
|
||||||
|
|
||||||
|
matches_list.append({
|
||||||
|
'id': p_id,
|
||||||
|
'name': name,
|
||||||
|
'probability': probability
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by probability
|
||||||
|
matches_list.sort(key=lambda x: x['probability'], reverse=True)
|
||||||
|
|
||||||
|
return jsonify({'matches': matches_list})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing image: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/train', methods=['POST'])
|
||||||
|
def train_face():
|
||||||
|
data = request.json
|
||||||
|
if 'partner_id' not in data or 'images' not in data:
|
||||||
|
return jsonify({'error': 'Missing partner_id or images'}), 400
|
||||||
|
|
||||||
|
partner_id = data['partner_id']
|
||||||
|
name = data.get('name', 'Unknown')
|
||||||
|
images = data['images'] # List of base64 strings
|
||||||
|
|
||||||
|
faces_dir = "faces"
|
||||||
|
if not os.path.exists(faces_dir):
|
||||||
|
os.makedirs(faces_dir)
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
# Clear existing images for this partner to avoid duplicates/stale data?
|
||||||
|
# For simplicity, we might just overwrite or append. Let's append with index.
|
||||||
|
# Or better, delete old ones first if we want to keep it clean.
|
||||||
|
# Let's just save new ones for now.
|
||||||
|
|
||||||
|
for idx, img_data in enumerate(images):
|
||||||
|
if not img_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode base64
|
||||||
|
image_bytes = base64.b64decode(img_data)
|
||||||
|
filename = f"{partner_id}_{name.replace(' ', '_')}_{idx}.jpg"
|
||||||
|
filepath = os.path.join(faces_dir, filename)
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
# Update in-memory knowledge
|
||||||
|
image = face_recognition.load_image_file(filepath)
|
||||||
|
encodings = face_recognition.face_encodings(image)
|
||||||
|
|
||||||
|
if encodings:
|
||||||
|
known_face_encodings.append(encodings[0])
|
||||||
|
known_face_names.append(name)
|
||||||
|
known_face_ids.append(partner_id)
|
||||||
|
processed_count += 1
|
||||||
|
print(f"Trained face for: {name} (ID: {partner_id}) - Image {idx}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving/training image {idx} for {name}: {e}")
|
||||||
|
|
||||||
|
return jsonify({'message': f'Processed {processed_count} images for {name}'})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
load_known_faces()
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
||||||
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_0.jpg
Normal file
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_0.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_1.jpg
Normal file
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_2.jpg
Normal file
BIN
ai_face_recognition_server/faces/19_Suherdy_Yacob_58_2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
5
ai_face_recognition_server/requirements.txt
Normal file
5
ai_face_recognition_server/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
flask
|
||||||
|
flask-cors
|
||||||
|
face_recognition
|
||||||
|
numpy
|
||||||
|
opencv-python
|
||||||
4
models/__init__.py
Normal file
4
models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from . import res_partner
|
||||||
|
from . import res_config_settings
|
||||||
|
from . import pos_config
|
||||||
|
|
||||||
10
models/pos_config.py
Normal file
10
models/pos_config.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class PosConfig(models.Model):
|
||||||
|
_inherit = 'pos.config'
|
||||||
|
|
||||||
|
pos_face_rec_server_url = fields.Char(
|
||||||
|
string="Face Recognition Server URL",
|
||||||
|
help="URL of the AI Face Recognition Server (e.g., http://localhost:5000)"
|
||||||
|
)
|
||||||
12
models/res_config_settings.py
Normal file
12
models/res_config_settings.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
pos_face_rec_server_url = fields.Char(
|
||||||
|
string="AI Face Recognition Server URL",
|
||||||
|
related='pos_config_id.pos_face_rec_server_url',
|
||||||
|
readonly=False,
|
||||||
|
help="URL of the AI Face Recognition Server (e.g., http://localhost:5000)"
|
||||||
|
)
|
||||||
74
models/res_partner.py
Normal file
74
models/res_partner.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
image_face_1 = fields.Binary("Face Image 1", attachment=True)
|
||||||
|
image_face_2 = fields.Binary("Face Image 2", attachment=True)
|
||||||
|
image_face_3 = fields.Binary("Face Image 3", attachment=True)
|
||||||
|
|
||||||
|
def _sync_face_images(self):
|
||||||
|
# Get the server URL from any POS config that has it configured
|
||||||
|
pos_config = self.env['pos.config'].search([('pos_face_rec_server_url', '!=', False)], limit=1)
|
||||||
|
if not pos_config or not pos_config.pos_face_rec_server_url:
|
||||||
|
_logger.warning("Face Recognition Server URL is not configured in any POS configuration")
|
||||||
|
return
|
||||||
|
|
||||||
|
server_url = pos_config.pos_face_rec_server_url
|
||||||
|
|
||||||
|
for partner in self:
|
||||||
|
# Only sync if at least one image is present
|
||||||
|
if not any([partner.image_face_1, partner.image_face_2, partner.image_face_3]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
images = [
|
||||||
|
partner.image_face_1.decode('utf-8') if isinstance(partner.image_face_1, bytes) else partner.image_face_1,
|
||||||
|
partner.image_face_2.decode('utf-8') if isinstance(partner.image_face_2, bytes) else partner.image_face_2,
|
||||||
|
partner.image_face_3.decode('utf-8') if isinstance(partner.image_face_3, bytes) else partner.image_face_3
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
'images': images
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove trailing slash if present
|
||||||
|
base_url = server_url.rstrip('/')
|
||||||
|
requests.post(f"{base_url}/train", json=payload, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to sync face images for partner {partner.id}: {e}")
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
partners = super().create(vals_list)
|
||||||
|
partners._sync_face_images()
|
||||||
|
return partners
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
if any(f in vals for f in ['image_face_1', 'image_face_2', 'image_face_3', 'name']):
|
||||||
|
self._sync_face_images()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_top_products(self):
|
||||||
|
self.ensure_one()
|
||||||
|
# Simple implementation: get top 3 products by quantity from past orders
|
||||||
|
query = """
|
||||||
|
SELECT pt.name, sum(pol.qty) as qty
|
||||||
|
FROM pos_order_line pol
|
||||||
|
JOIN pos_order po ON pol.order_id = po.id
|
||||||
|
JOIN product_product pp ON pol.product_id = pp.id
|
||||||
|
JOIN product_template pt ON pp.product_tmpl_id = pt.id
|
||||||
|
WHERE po.partner_id = %s
|
||||||
|
GROUP BY pt.name
|
||||||
|
ORDER BY qty DESC
|
||||||
|
LIMIT 3
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(query, (self.id,))
|
||||||
|
return [{'name': row[0], 'qty': row[1]} for row in self.env.cr.fetchall()]
|
||||||
255
static/src/css/face_recognition.css
Normal file
255
static/src/css/face_recognition.css
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
.face-recognition-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 65px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 300px;
|
||||||
|
background: white;
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 10px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container video {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-item:hover {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-details {
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 2px solid #eee;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-recognition-sidebar .control-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-recognition-sidebar.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add padding to ProductScreen to avoid sidebar overlap */
|
||||||
|
.pos .product-screen {
|
||||||
|
padding-right: 305px;
|
||||||
|
}
|
||||||
|
/* Order
|
||||||
|
Details Styling */
|
||||||
|
.last-orders {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-orders h5 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .label {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .value {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .value.status {
|
||||||
|
text-transform: capitalize;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .value.status.paid {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .value.status.done {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row .value.status.invoiced {
|
||||||
|
background: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-products {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-qty {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-orders {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Products Styling */
|
||||||
|
.top-products {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-products h5 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-products ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-products li {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 3px solid #ffc107;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
184
static/src/js/face_recognition_sidebar.js
Normal file
184
static/src/js/face_recognition_sidebar.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||||
|
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class FaceRecognitionSidebar extends Component {
|
||||||
|
static template = "pos_face_recognition.FaceRecognitionSidebar";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.pos = useService("pos");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
console.log("FaceRecognitionSidebar setup");
|
||||||
|
this.state = useState({
|
||||||
|
matches: [],
|
||||||
|
selectedCustomer: null,
|
||||||
|
lastOrders: [],
|
||||||
|
topProducts: [],
|
||||||
|
});
|
||||||
|
this.recognitionInterval = null;
|
||||||
|
this.stream = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
this.startCamera();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup interval and camera when component is destroyed
|
||||||
|
onWillUnmount(() => {
|
||||||
|
if (this.recognitionInterval) {
|
||||||
|
clearInterval(this.recognitionInterval);
|
||||||
|
}
|
||||||
|
if (this.stream) {
|
||||||
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async startCamera() {
|
||||||
|
console.log("startCamera called");
|
||||||
|
try {
|
||||||
|
const video = document.getElementById('face-rec-video');
|
||||||
|
console.log("Video element found:", video);
|
||||||
|
if (video) {
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
console.log("Camera stream obtained:", this.stream);
|
||||||
|
video.srcObject = this.stream;
|
||||||
|
|
||||||
|
// Start real-time recognition loop once camera is ready
|
||||||
|
this.startRealTimeRecognition();
|
||||||
|
} else {
|
||||||
|
console.error("Video element not found!");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error accessing camera:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
this.recognitionInterval = setInterval(() => {
|
||||||
|
console.log("Recognition interval tick");
|
||||||
|
this.captureAndSendFrame();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureAndSendFrame() {
|
||||||
|
const video = document.getElementById('face-rec-video');
|
||||||
|
if (!video) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||||
|
// Convert to base64, remove header
|
||||||
|
const imageData = canvas.toDataURL('image/jpeg').split(',')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.pos.config.pos_face_rec_server_url + '/recognize', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ image: imageData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.matches && result.matches.length > 0) {
|
||||||
|
// Enrich matches with partner phone numbers
|
||||||
|
this.state.matches = result.matches.map(match => {
|
||||||
|
const partner = this.pos.db.get_partner_by_id(match.id);
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
phone: partner ? partner.phone || partner.mobile : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error sending frame to AI server:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectCustomer(match) {
|
||||||
|
const partner = this.pos.db.get_partner_by_id(match.id);
|
||||||
|
if (partner) {
|
||||||
|
this.state.selectedCustomer = partner;
|
||||||
|
this.pos.get_order().set_partner(partner);
|
||||||
|
|
||||||
|
// Fetch details
|
||||||
|
await this.fetchCustomerDetails(partner.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCustomerDetails(partnerId) {
|
||||||
|
// Fetch last 2 orders with detailed information
|
||||||
|
const orders = await this.orm.searchRead("pos.order",
|
||||||
|
[['partner_id', '=', partnerId]],
|
||||||
|
['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) {
|
||||||
|
const lines = await this.orm.searchRead("pos.order.line",
|
||||||
|
[['id', 'in', order.lines]],
|
||||||
|
['product_id', 'qty', 'price_subtotal_incl', 'full_product_name'],
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
// Add product names to lines
|
||||||
|
order.lines = lines.map(line => ({
|
||||||
|
...line,
|
||||||
|
product_name: line.full_product_name || (line.product_id && line.product_id[1]) || 'Unknown Product'
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
order.lines = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.lastOrders = orders;
|
||||||
|
|
||||||
|
// Fetch top 3 products
|
||||||
|
try {
|
||||||
|
const topProducts = await this.orm.call("res.partner", "get_top_products", [partnerId]);
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
console.error("topProducts is not an array:", topProducts);
|
||||||
|
this.state.topProducts = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching top products:", error);
|
||||||
|
this.state.topProducts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductScreen.components = {
|
||||||
|
...ProductScreen.components,
|
||||||
|
FaceRecognitionSidebar,
|
||||||
|
};
|
||||||
20
static/src/js/models.js
Normal file
20
static/src/js/models.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { PosStore } from "@point_of_sale/app/store/pos_store";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(PosStore.prototype, {
|
||||||
|
async _processData(loadedData) {
|
||||||
|
await super._processData(...arguments);
|
||||||
|
// We don't necessarily need to load the full image data for all partners on load
|
||||||
|
// because it would be heavy. We only need to send it back when saving.
|
||||||
|
// However, if we want to show existing images in the edit screen, we might need them.
|
||||||
|
// But standard Odoo doesn't load full images for partners in POS to save bandwidth.
|
||||||
|
// It usually loads small avatars.
|
||||||
|
// For now, let's NOT load them by default to avoid performance issues.
|
||||||
|
// The edit screen will show empty if not modified in current session,
|
||||||
|
// or we could fetch them on demand (like `partnerImageUrl` does).
|
||||||
|
// BUT, `saveChanges` in `partner_editor.js` sends `processedChanges`.
|
||||||
|
// If we want to support editing, we need to handle the fields.
|
||||||
|
}
|
||||||
|
});
|
||||||
107
static/src/js/partner_details_edit.js
Normal file
107
static/src/js/partner_details_edit.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { PartnerDetailsEdit } from "@point_of_sale/app/screens/partner_list/partner_editor/partner_editor";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { getDataURLFromFile } from "@web/core/utils/urls";
|
||||||
|
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { loadImage } from "@point_of_sale/utils";
|
||||||
|
import { useRef, useState, onWillUnmount } from "@odoo/owl";
|
||||||
|
|
||||||
|
patch(PartnerDetailsEdit.prototype, {
|
||||||
|
setup() {
|
||||||
|
super.setup(...arguments);
|
||||||
|
this.changes.image_face_1 = this.changes.image_face_1 || false;
|
||||||
|
this.changes.image_face_2 = this.changes.image_face_2 || false;
|
||||||
|
this.changes.image_face_3 = this.changes.image_face_3 || false;
|
||||||
|
|
||||||
|
this.camera = useState({
|
||||||
|
activeField: null,
|
||||||
|
});
|
||||||
|
this.videoRef = useRef("video");
|
||||||
|
|
||||||
|
onWillUnmount(() => {
|
||||||
|
this.stopCamera();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCamera(fieldName) {
|
||||||
|
if (this.camera.activeField) {
|
||||||
|
this.stopCamera();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
this.camera.activeField = fieldName;
|
||||||
|
this.stream = stream;
|
||||||
|
// Wait for the video element to be rendered
|
||||||
|
await Promise.resolve();
|
||||||
|
// We might need a small delay or rely on the ref being attached on render
|
||||||
|
// Since we set state, a render will happen. The ref should be available after render.
|
||||||
|
// However, in Owl, refs are usually available after patch.
|
||||||
|
// Let's try setting it in a next tick or just relying on the template to use the ref.
|
||||||
|
// Actually, we need to set srcObject.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.videoRef.el) {
|
||||||
|
this.videoRef.el.srcObject = stream;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Camera error", err);
|
||||||
|
this.popup.add(ErrorPopup, { title: _t("Camera Error"), body: _t("Could not access camera.") });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopCamera() {
|
||||||
|
if (this.stream) {
|
||||||
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
|
this.stream = null;
|
||||||
|
}
|
||||||
|
this.camera.activeField = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
capturePhoto() {
|
||||||
|
if (!this.videoRef.el) return;
|
||||||
|
const video = this.videoRef.el;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||||
|
// Remove data:image/jpeg;base64, prefix
|
||||||
|
this.changes[this.camera.activeField] = dataUrl.split(',')[1];
|
||||||
|
this.stopCamera();
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadFaceImage(event, fieldName) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.match(/image.*/)) {
|
||||||
|
await this.popup.add(ErrorPopup, {
|
||||||
|
title: _t("Unsupported File Format"),
|
||||||
|
body: _t("Only web-compatible Image formats such as .png or .jpeg are supported."),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = await getDataURLFromFile(file);
|
||||||
|
const loadedImage = await loadImage(imageUrl, {
|
||||||
|
onError: () => {
|
||||||
|
this.popup.add(ErrorPopup, {
|
||||||
|
title: _t("Loading Image Error"),
|
||||||
|
body: _t("Encountered error when loading image. Please try again."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loadedImage) {
|
||||||
|
// Resize to reasonable size for face recognition (e.g. 800x600 is usually enough)
|
||||||
|
const resizedImage = await this._resizeImage(loadedImage, 800, 600);
|
||||||
|
const dataUrl = resizedImage.toDataURL();
|
||||||
|
// Remove data:image/png;base64, prefix
|
||||||
|
this.changes[fieldName] = dataUrl.split(',')[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
104
static/src/xml/face_recognition_screens.xml
Normal file
104
static/src/xml/face_recognition_screens.xml
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="pos_face_recognition.FaceRecognitionButton" owl="1">
|
||||||
|
<div class="control-button" t-on-click="onClick">
|
||||||
|
<i class="fa fa-camera" role="img" aria-label="Face Recognition" title="Face Recognition"/>
|
||||||
|
<span>Face Rec.</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="pos_face_recognition.FaceRecognitionSidebar" owl="1">
|
||||||
|
<div class="face-recognition-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Face Recognition</h3>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="camera-container">
|
||||||
|
<video id="face-rec-video" autoplay="true" playsinline="true" width="100%"></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-container" t-if="state.matches.length > 0">
|
||||||
|
<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">
|
||||||
|
<div class="match-info">
|
||||||
|
<div>
|
||||||
|
<span class="match-name"><t t-esc="match.name"/></span>
|
||||||
|
<span class="match-phone" t-if="match.phone"><br/><t t-esc="match.phone"/></span>
|
||||||
|
</div>
|
||||||
|
<span class="match-prob"><t t-esc="(match.probability * 100).toFixed(1)"/>%</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="customer-details" t-if="state.selectedCustomer">
|
||||||
|
<h4>Selected: <t t-esc="state.selectedCustomer.name"/></h4>
|
||||||
|
|
||||||
|
<div class="last-orders">
|
||||||
|
<h5>Last 2 Orders:</h5>
|
||||||
|
<div t-if="state.lastOrders and state.lastOrders.length > 0" class="orders-list">
|
||||||
|
<t t-foreach="state.lastOrders" t-as="order" t-key="order.id">
|
||||||
|
<div class="order-card">
|
||||||
|
<div class="order-header">
|
||||||
|
<span class="order-number">Order #<t t-esc="order.name || order.id"/></span>
|
||||||
|
<span class="order-date"><t t-esc="order.date_order"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="order-details">
|
||||||
|
<div class="order-row">
|
||||||
|
<span class="label">Total:</span>
|
||||||
|
<span class="value"><t t-esc="env.utils.formatCurrency(order.amount_total)"/></span>
|
||||||
|
</div>
|
||||||
|
<div class="order-row" t-if="order.lines and order.lines.length > 0">
|
||||||
|
<span class="label">Items:</span>
|
||||||
|
<span class="value"><t t-esc="order.lines.length"/> product(s)</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-row" t-if="order.state">
|
||||||
|
<span class="label">Status:</span>
|
||||||
|
<span class="value status" t-att-class="order.state"><t t-esc="order.state"/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="order-products" t-if="order.lines and order.lines.length > 0">
|
||||||
|
<div class="products-label">Products:</div>
|
||||||
|
<ul class="product-list">
|
||||||
|
<t t-foreach="order.lines" t-as="line" t-key="line.id">
|
||||||
|
<li class="product-item">
|
||||||
|
<span class="product-qty"><t t-esc="line.qty"/>x</span>
|
||||||
|
<span class="product-name"><t t-esc="line.product_name"/></span>
|
||||||
|
<span class="product-price"><t t-esc="env.utils.formatCurrency(line.price_subtotal_incl)"/></span>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<p t-else="" class="no-orders">No previous orders found</p>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
<div class="top-products">
|
||||||
|
<h5>Top 3 Products:</h5>
|
||||||
|
<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'])"/>)
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
<p t-else="">No previous orders found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="pos_face_recognition.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[hasclass('product-screen')]" position="inside">
|
||||||
|
<FaceRecognitionSidebar />
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
79
static/src/xml/partner_details_edit.xml
Normal file
79
static/src/xml/partner_details_edit.xml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="pos_face_recognition.PartnerDetailsEdit" t-inherit="point_of_sale.PartnerDetailsEdit" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[hasclass('partner-details-box')]" position="inside">
|
||||||
|
<div class="partner-detail col">
|
||||||
|
<label class="form-label label">Face Image 1</label>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
<t t-if="camera.activeField === 'image_face_1'">
|
||||||
|
<div class="position-relative text-center">
|
||||||
|
<video t-ref="video" autoplay="autoplay" playsinline="playsinline" style="width: 100%; max-width: 300px; background: #000;" />
|
||||||
|
<div class="d-flex gap-2 mt-1 justify-content-center">
|
||||||
|
<button class="btn btn-primary" t-on-click="capturePhoto">Capture</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="stopCamera">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<t t-if="changes.image_face_1">
|
||||||
|
<img t-att-src="'data:image/jpeg;base64,' + changes.image_face_1" style="width: 64px; height: 64px; object-fit: cover;" class="rounded"/>
|
||||||
|
</t>
|
||||||
|
<button class="btn btn-secondary" t-on-click="() => this.startCamera('image_face_1')">
|
||||||
|
<i class="fa fa-camera"/> Take Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="partner-detail col">
|
||||||
|
<label class="form-label label">Face Image 2</label>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
<t t-if="camera.activeField === 'image_face_2'">
|
||||||
|
<div class="position-relative text-center">
|
||||||
|
<video t-ref="video" autoplay="autoplay" playsinline="playsinline" style="width: 100%; max-width: 300px; background: #000;" />
|
||||||
|
<div class="d-flex gap-2 mt-1 justify-content-center">
|
||||||
|
<button class="btn btn-primary" t-on-click="capturePhoto">Capture</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="stopCamera">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<t t-if="changes.image_face_2">
|
||||||
|
<img t-att-src="'data:image/jpeg;base64,' + changes.image_face_2" style="width: 64px; height: 64px; object-fit: cover;" class="rounded"/>
|
||||||
|
</t>
|
||||||
|
<button class="btn btn-secondary" t-on-click="() => this.startCamera('image_face_2')">
|
||||||
|
<i class="fa fa-camera"/> Take Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="partner-detail col">
|
||||||
|
<label class="form-label label">Face Image 3</label>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
<t t-if="camera.activeField === 'image_face_3'">
|
||||||
|
<div class="position-relative text-center">
|
||||||
|
<video t-ref="video" autoplay="autoplay" playsinline="playsinline" style="width: 100%; max-width: 300px; background: #000;" />
|
||||||
|
<div class="d-flex gap-2 mt-1 justify-content-center">
|
||||||
|
<button class="btn btn-primary" t-on-click="capturePhoto">Capture</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="stopCamera">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<t t-if="changes.image_face_3">
|
||||||
|
<img t-att-src="'data:image/jpeg;base64,' + changes.image_face_3" style="width: 64px; height: 64px; object-fit: cover;" class="rounded"/>
|
||||||
|
</t>
|
||||||
|
<button class="btn btn-secondary" t-on-click="() => this.startCamera('image_face_3')">
|
||||||
|
<i class="fa fa-camera"/> Take Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
15
views/pos_config_views.xml
Normal file
15
views/pos_config_views.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="pos_config_view_form_inherit_face_recognition" model="ir.ui.view">
|
||||||
|
<field name="name">pos.config.view.form.inherit.face.recognition</field>
|
||||||
|
<field name="model">pos.config</field>
|
||||||
|
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@class='row mt16 o_settings_container']/div[@groups='base.group_system']" position="before">
|
||||||
|
<setting string="Face Recognition" help="Configure the AI server for face recognition.">
|
||||||
|
<field name="pos_face_rec_server_url" placeholder="http://localhost:5000"/>
|
||||||
|
</setting>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
15
views/res_config_settings_views.xml
Normal file
15
views/res_config_settings_views.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.inherit.pos.face.recognition</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//block[@id='pos_interface_section']" position="inside">
|
||||||
|
<setting string="Face Recognition" help="Configure the AI server for face recognition.">
|
||||||
|
<field name="pos_face_rec_server_url" placeholder="http://localhost:5000"/>
|
||||||
|
</setting>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
21
views/res_partner_views.xml
Normal file
21
views/res_partner_views.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_partner_form_face_recognition" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.face.recognition</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page string="Face Recognition" name="face_recognition">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="image_face_1" widget="image" class="oe_avatar"/>
|
||||||
|
<field name="image_face_2" widget="image" class="oe_avatar"/>
|
||||||
|
<field name="image_face_3" widget="image" class="oe_avatar"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue
Block a user