Spaces:
Sleeping
Sleeping
Upload 9 files
Browse files- .gitignore +167 -0
- README.md +165 -0
- app.py +219 -0
- config.py +66 -0
- requirements.txt +6 -0
- utils/__init__.py +1 -0
- utils/ai_analyzer.py +181 -0
- utils/recommendations.py +213 -0
- utils/ui_components.py +302 -0
.gitignore
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
pip-wheel-metadata/
|
| 24 |
+
share/python-wheels/
|
| 25 |
+
*.egg-info/
|
| 26 |
+
.installed.cfg
|
| 27 |
+
*.egg
|
| 28 |
+
MANIFEST
|
| 29 |
+
|
| 30 |
+
# PyInstaller
|
| 31 |
+
# Usually these files are written by a python script from a template
|
| 32 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 33 |
+
*.manifest
|
| 34 |
+
*.spec
|
| 35 |
+
|
| 36 |
+
# Installer logs
|
| 37 |
+
pip-log.txt
|
| 38 |
+
pip-delete-this-directory.txt
|
| 39 |
+
|
| 40 |
+
# Unit test / coverage reports
|
| 41 |
+
htmlcov/
|
| 42 |
+
.tox/
|
| 43 |
+
.nox/
|
| 44 |
+
.coverage
|
| 45 |
+
.coverage.*
|
| 46 |
+
.cache
|
| 47 |
+
nosetests.xml
|
| 48 |
+
coverage.xml
|
| 49 |
+
*.cover
|
| 50 |
+
*.py,cover
|
| 51 |
+
.hypothesis/
|
| 52 |
+
.pytest_cache/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
target/
|
| 76 |
+
|
| 77 |
+
# Jupyter Notebook
|
| 78 |
+
.ipynb_checkpoints
|
| 79 |
+
|
| 80 |
+
# IPython
|
| 81 |
+
profile_default/
|
| 82 |
+
ipython_config.py
|
| 83 |
+
|
| 84 |
+
# pyenv
|
| 85 |
+
.python-version
|
| 86 |
+
|
| 87 |
+
# pipenv
|
| 88 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 89 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 90 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 91 |
+
# install all needed dependencies.
|
| 92 |
+
#Pipfile.lock
|
| 93 |
+
|
| 94 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
| 95 |
+
__pypackages__/
|
| 96 |
+
|
| 97 |
+
# Celery stuff
|
| 98 |
+
celerybeat-schedule
|
| 99 |
+
celerybeat.pid
|
| 100 |
+
|
| 101 |
+
# SageMath parsed files
|
| 102 |
+
*.sage.py
|
| 103 |
+
|
| 104 |
+
# Environments
|
| 105 |
+
.env
|
| 106 |
+
.venv
|
| 107 |
+
env/
|
| 108 |
+
venv/
|
| 109 |
+
ENV/
|
| 110 |
+
env.bak/
|
| 111 |
+
venv.bak/
|
| 112 |
+
|
| 113 |
+
# Spyder project settings
|
| 114 |
+
.spyderproject
|
| 115 |
+
.spyproject
|
| 116 |
+
|
| 117 |
+
# Rope project settings
|
| 118 |
+
.ropeproject
|
| 119 |
+
|
| 120 |
+
# mkdocs documentation
|
| 121 |
+
/site
|
| 122 |
+
|
| 123 |
+
# mypy
|
| 124 |
+
.mypy_cache/
|
| 125 |
+
.dmypy.json
|
| 126 |
+
dmypy.json
|
| 127 |
+
|
| 128 |
+
# Pyre type checker
|
| 129 |
+
.pyre/
|
| 130 |
+
|
| 131 |
+
# Streamlit
|
| 132 |
+
.streamlit/
|
| 133 |
+
|
| 134 |
+
# API Keys and secrets
|
| 135 |
+
.secrets
|
| 136 |
+
*.key
|
| 137 |
+
api_keys.txt
|
| 138 |
+
config_secrets.py
|
| 139 |
+
|
| 140 |
+
# Images and uploads
|
| 141 |
+
uploads/
|
| 142 |
+
temp_images/
|
| 143 |
+
*.jpg
|
| 144 |
+
*.jpeg
|
| 145 |
+
*.png
|
| 146 |
+
*.gif
|
| 147 |
+
*.bmp
|
| 148 |
+
*.webp
|
| 149 |
+
|
| 150 |
+
# Logs
|
| 151 |
+
*.log
|
| 152 |
+
logs/
|
| 153 |
+
|
| 154 |
+
# IDE
|
| 155 |
+
.vscode/
|
| 156 |
+
.idea/
|
| 157 |
+
*.swp
|
| 158 |
+
*.swo
|
| 159 |
+
*~
|
| 160 |
+
|
| 161 |
+
# OS
|
| 162 |
+
.DS_Store
|
| 163 |
+
Thumbs.db
|
| 164 |
+
|
| 165 |
+
# Temporary files
|
| 166 |
+
*.tmp
|
| 167 |
+
*.temp
|
README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏪 Shelf Photo Analyzer
|
| 2 |
+
|
| 3 |
+
## Description
|
| 4 |
+
|
| 5 |
+
An AI-powered tool for merchandisers and retail managers to instantly analyze product displays on store shelves. Simply upload a shelf photo, enter the product name, and get instant AI analysis with actionable recommendations to improve product placement and boost sales.
|
| 6 |
+
|
| 7 |
+
## Features
|
| 8 |
+
|
| 9 |
+
- 📸 **Photo Upload** - Support for JPG, PNG, WebP formats up to 10MB
|
| 10 |
+
- 🔍 **Product Search** - AI-powered product detection and analysis
|
| 11 |
+
- 🤖 **Smart Analysis** - Uses GPT-4 Vision API for accurate shelf assessment
|
| 12 |
+
- 📊 **Compliance Scoring** - 1-10 score for product placement effectiveness
|
| 13 |
+
- 💡 **Actionable Recommendations** - Specific suggestions with impact estimates
|
| 14 |
+
- 📱 **Mobile Friendly** - Responsive design for field use
|
| 15 |
+
- 📈 **Impact Forecasting** - Estimated sales improvement from changes
|
| 16 |
+
|
| 17 |
+
## How to Use
|
| 18 |
+
|
| 19 |
+
1. **Upload** a clear photo of the store shelf
|
| 20 |
+
2. **Enter** the product name you want to analyze
|
| 21 |
+
3. **Select** analysis depth (Basic/Detailed/Expert)
|
| 22 |
+
4. **Click** "Analyze Photo" and wait 15-30 seconds
|
| 23 |
+
5. **Review** results and follow the recommendations
|
| 24 |
+
|
| 25 |
+
## Analysis Results
|
| 26 |
+
|
| 27 |
+
The AI provides comprehensive analysis including:
|
| 28 |
+
|
| 29 |
+
- ✅ **Product Detection** - Whether product is visible on shelf
|
| 30 |
+
- 📦 **Facing Count** - Number of visible product units
|
| 31 |
+
- 📍 **Shelf Position** - Top/middle/bottom placement
|
| 32 |
+
- 💰 **Price Visibility** - Whether pricing is clearly shown
|
| 33 |
+
- 🧹 **Product Condition** - Cleanliness and damage assessment
|
| 34 |
+
- 🏆 **Overall Score** - 1-10 rating for placement effectiveness
|
| 35 |
+
- 🎯 **Confidence Level** - AI certainty in the analysis
|
| 36 |
+
|
| 37 |
+
## Recommendations Engine
|
| 38 |
+
|
| 39 |
+
Get prioritized action items with:
|
| 40 |
+
|
| 41 |
+
- 🚨 **Priority Levels** - Critical, high, medium, low priorities
|
| 42 |
+
- ⏱️ **Time Estimates** - How long each fix will take
|
| 43 |
+
- 📈 **Impact Predictions** - Expected sales/visibility improvements
|
| 44 |
+
- 🔧 **Difficulty Rating** - How easy each recommendation is to implement
|
| 45 |
+
|
| 46 |
+
## Tech Stack
|
| 47 |
+
|
| 48 |
+
- **Frontend:** Streamlit
|
| 49 |
+
- **AI:** OpenAI GPT-4 Vision API
|
| 50 |
+
- **Image Processing:** Pillow (PIL)
|
| 51 |
+
- **Backend:** Python 3.9+
|
| 52 |
+
- **Hosting:** Hugging Face Spaces
|
| 53 |
+
|
| 54 |
+
## Setup Instructions
|
| 55 |
+
|
| 56 |
+
### For Hugging Face Spaces Deployment
|
| 57 |
+
|
| 58 |
+
1. **Fork/Clone** this repository
|
| 59 |
+
2. **Create** a new Hugging Face Space
|
| 60 |
+
3. **Select** "Streamlit" as the SDK
|
| 61 |
+
4. **Upload** all files to your Space
|
| 62 |
+
5. **Add** your OpenAI API key to Space secrets:
|
| 63 |
+
- Go to Settings → Repository secrets
|
| 64 |
+
- Add: `OPENAI_API_KEY = "your-api-key-here"`
|
| 65 |
+
6. **Deploy** - your app will be live in minutes!
|
| 66 |
+
|
| 67 |
+
### Local Development
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
# Clone repository
|
| 71 |
+
git clone <repository-url>
|
| 72 |
+
cd shelf-analyzer
|
| 73 |
+
|
| 74 |
+
# Install dependencies
|
| 75 |
+
pip install -r requirements.txt
|
| 76 |
+
|
| 77 |
+
# Set environment variable
|
| 78 |
+
export OPENAI_API_KEY="your-api-key-here"
|
| 79 |
+
|
| 80 |
+
# Run application
|
| 81 |
+
streamlit run app.py
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
## Configuration
|
| 85 |
+
|
| 86 |
+
The app uses the following configuration (in `config.py`):
|
| 87 |
+
|
| 88 |
+
- **Max Image Size:** 10MB
|
| 89 |
+
- **Supported Formats:** JPG, JPEG, PNG, WebP
|
| 90 |
+
- **AI Model:** GPT-4 Vision Preview
|
| 91 |
+
- **Analysis Timeout:** 60 seconds
|
| 92 |
+
- **Max Retries:** 3 attempts
|
| 93 |
+
|
| 94 |
+
## File Structure
|
| 95 |
+
|
| 96 |
+
```
|
| 97 |
+
shelf-analyzer/
|
| 98 |
+
├── app.py # Main Streamlit application
|
| 99 |
+
├── requirements.txt # Python dependencies
|
| 100 |
+
├── config.py # Configuration settings
|
| 101 |
+
├── README.md # This file
|
| 102 |
+
├── utils/
|
| 103 |
+
│ ├── __init__.py
|
| 104 |
+
│ ├── ai_analyzer.py # AI analysis logic
|
| 105 |
+
│ ├── recommendations.py # Recommendation engine
|
| 106 |
+
│ └── ui_components.py # UI display components
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## Usage Examples
|
| 110 |
+
|
| 111 |
+
### Typical Use Cases
|
| 112 |
+
|
| 113 |
+
- **Merchandisers:** Check product compliance during store visits
|
| 114 |
+
- **Category Managers:** Audit shelf execution across locations
|
| 115 |
+
- **Store Managers:** Optimize product placement for better sales
|
| 116 |
+
- **Retail Auditors:** Document and track display improvements
|
| 117 |
+
|
| 118 |
+
### Sample Workflow
|
| 119 |
+
|
| 120 |
+
1. Walk store aisles with smartphone/tablet
|
| 121 |
+
2. Take photos of key product categories
|
| 122 |
+
3. Analyze each product's shelf presence
|
| 123 |
+
4. Follow AI recommendations immediately
|
| 124 |
+
5. Re-analyze to confirm improvements
|
| 125 |
+
|
| 126 |
+
## API Costs
|
| 127 |
+
|
| 128 |
+
- **Hugging Face Hosting:** FREE ✨
|
| 129 |
+
- **OpenAI API:** ~$0.01-0.04 per analysis
|
| 130 |
+
- **Monthly Estimate:** $20-100 (depending on usage)
|
| 131 |
+
|
| 132 |
+
## Limitations
|
| 133 |
+
|
| 134 |
+
- Requires good lighting in photos
|
| 135 |
+
- Works best with clear, unobstructed shelf views
|
| 136 |
+
- AI accuracy depends on image quality
|
| 137 |
+
- Currently supports single product analysis per photo
|
| 138 |
+
|
| 139 |
+
## Future Roadmap
|
| 140 |
+
|
| 141 |
+
### Phase 2
|
| 142 |
+
- [ ] Batch analysis of multiple products
|
| 143 |
+
- [ ] PDF report generation
|
| 144 |
+
- [ ] Analysis history dashboard
|
| 145 |
+
- [ ] Popular product presets
|
| 146 |
+
|
| 147 |
+
### Phase 3
|
| 148 |
+
- [ ] Mobile app development
|
| 149 |
+
- [ ] Real-time video analysis
|
| 150 |
+
- [ ] Planogram compliance checking
|
| 151 |
+
- [ ] Team collaboration features
|
| 152 |
+
|
| 153 |
+
## Support
|
| 154 |
+
|
| 155 |
+
For issues, questions, or feature requests:
|
| 156 |
+
- Create an issue in this repository
|
| 157 |
+
- Contact: [your-contact-info]
|
| 158 |
+
|
| 159 |
+
## License
|
| 160 |
+
|
| 161 |
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
**Powered by OpenAI GPT-4 Vision API**
|
app.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import base64
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from utils.ai_analyzer import analyze_shelf_image
|
| 7 |
+
from utils.recommendations import generate_recommendations
|
| 8 |
+
from utils.ui_components import display_results, display_recommendations
|
| 9 |
+
|
| 10 |
+
st.set_page_config(
|
| 11 |
+
page_title="Shelf Photo Analyzer",
|
| 12 |
+
page_icon="🏪",
|
| 13 |
+
layout="wide",
|
| 14 |
+
initial_sidebar_state="collapsed"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
st.markdown("""
|
| 18 |
+
<style>
|
| 19 |
+
.main-header {
|
| 20 |
+
font-size: 3rem;
|
| 21 |
+
color: #FF6B6B;
|
| 22 |
+
text-align: center;
|
| 23 |
+
margin-bottom: 1rem;
|
| 24 |
+
}
|
| 25 |
+
.subtitle {
|
| 26 |
+
font-size: 1.2rem;
|
| 27 |
+
text-align: center;
|
| 28 |
+
color: #666;
|
| 29 |
+
margin-bottom: 2rem;
|
| 30 |
+
}
|
| 31 |
+
.upload-section {
|
| 32 |
+
border: 2px dashed #FF6B6B;
|
| 33 |
+
border-radius: 10px;
|
| 34 |
+
padding: 2rem;
|
| 35 |
+
text-align: center;
|
| 36 |
+
margin: 1rem 0;
|
| 37 |
+
}
|
| 38 |
+
.results-container {
|
| 39 |
+
background-color: #f8f9fa;
|
| 40 |
+
border-radius: 10px;
|
| 41 |
+
padding: 1.5rem;
|
| 42 |
+
margin: 1rem 0;
|
| 43 |
+
}
|
| 44 |
+
</style>
|
| 45 |
+
""", unsafe_allow_html=True)
|
| 46 |
+
|
| 47 |
+
def main():
|
| 48 |
+
st.markdown('<h1 class="main-header">🏪 Shelf Photo Analyzer</h1>', unsafe_allow_html=True)
|
| 49 |
+
st.markdown('<p class="subtitle">Błyskawiczna analiza ekspozycji produktów na półkach sklepowych przy użyciu AI</p>', unsafe_allow_html=True)
|
| 50 |
+
|
| 51 |
+
# Initialize session state
|
| 52 |
+
if 'analysis_results' not in st.session_state:
|
| 53 |
+
st.session_state.analysis_results = None
|
| 54 |
+
if 'recommendations' not in st.session_state:
|
| 55 |
+
st.session_state.recommendations = None
|
| 56 |
+
if 'analysis_history' not in st.session_state:
|
| 57 |
+
st.session_state.analysis_history = []
|
| 58 |
+
if 'openai_api_key' not in st.session_state:
|
| 59 |
+
st.session_state.openai_api_key = ''
|
| 60 |
+
|
| 61 |
+
# API Key input
|
| 62 |
+
st.markdown("### 🔑 OpenAI API Configuration")
|
| 63 |
+
api_key_input = st.text_input(
|
| 64 |
+
"Enter your OpenAI API Key",
|
| 65 |
+
type="password",
|
| 66 |
+
value=st.session_state.openai_api_key,
|
| 67 |
+
placeholder="sk-...",
|
| 68 |
+
help="Your API key is stored only for this session and never saved permanently"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if api_key_input != st.session_state.openai_api_key:
|
| 72 |
+
st.session_state.openai_api_key = api_key_input
|
| 73 |
+
|
| 74 |
+
if not st.session_state.openai_api_key:
|
| 75 |
+
st.warning("⚠️ Please enter your OpenAI API key to use the analyzer. You can get one at: https://platform.openai.com/api-keys")
|
| 76 |
+
st.info("💡 **Tip:** Your API key is only stored temporarily in your browser session and is never saved permanently.")
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
st.success("✅ API key configured successfully!")
|
| 80 |
+
st.markdown("---")
|
| 81 |
+
|
| 82 |
+
# Main interface
|
| 83 |
+
col1, col2 = st.columns([1, 1])
|
| 84 |
+
|
| 85 |
+
with col1:
|
| 86 |
+
st.markdown("### 📸 Upload zdjęcia półki")
|
| 87 |
+
uploaded_file = st.file_uploader(
|
| 88 |
+
"Wybierz zdjęcie półki sklepowej",
|
| 89 |
+
type=['jpg', 'jpeg', 'png', 'webp'],
|
| 90 |
+
help="Obsługiwane formaty: JPG, PNG, WebP (max 10MB)"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if uploaded_file is not None:
|
| 94 |
+
try:
|
| 95 |
+
image = Image.open(uploaded_file)
|
| 96 |
+
st.image(image, caption="Uploaded Image", use_column_width=True)
|
| 97 |
+
st.session_state.uploaded_image = image
|
| 98 |
+
except Exception as e:
|
| 99 |
+
st.error(f"Błąd przy wczytywaniu obrazu: {str(e)}")
|
| 100 |
+
|
| 101 |
+
with col2:
|
| 102 |
+
st.markdown("### 🔍 Nazwa produktu")
|
| 103 |
+
product_name = st.text_input(
|
| 104 |
+
"Wpisz nazwę produktu do wyszukania",
|
| 105 |
+
placeholder="np. Coca-Cola 0.5L, Milka czekolada...",
|
| 106 |
+
help="Wpisz konkretną nazwę produktu, który chcesz znaleźć na półce"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
st.markdown("### ⚙️ Opcje analizy")
|
| 110 |
+
analysis_depth = st.selectbox(
|
| 111 |
+
"Poziom szczegółowości",
|
| 112 |
+
["Podstawowy", "Szczegółowy", "Ekspercki"],
|
| 113 |
+
help="Wybierz poziom analizy - im wyższy, tym więcej szczegółów"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Analysis button
|
| 117 |
+
if st.button("🚀 Analizuj zdjęcie", type="primary", use_container_width=True):
|
| 118 |
+
if not st.session_state.openai_api_key:
|
| 119 |
+
st.error("⚠️ Proszę najpierw wprowadzić klucz API!")
|
| 120 |
+
elif uploaded_file is None:
|
| 121 |
+
st.error("⚠️ Proszę najpierw wgrać zdjęcie!")
|
| 122 |
+
elif not product_name.strip():
|
| 123 |
+
st.error("⚠️ Proszę wpisać nazwę produktu!")
|
| 124 |
+
else:
|
| 125 |
+
with st.spinner("🤖 Analizuję zdjęcie... To może potrwać do 30 sekund"):
|
| 126 |
+
try:
|
| 127 |
+
# Convert image to base64 for API
|
| 128 |
+
buffered = BytesIO()
|
| 129 |
+
image.save(buffered, format="JPEG")
|
| 130 |
+
image_base64 = base64.b64encode(buffered.getvalue()).decode()
|
| 131 |
+
|
| 132 |
+
# Analyze image
|
| 133 |
+
analysis_results = analyze_shelf_image(
|
| 134 |
+
image_base64,
|
| 135 |
+
product_name,
|
| 136 |
+
analysis_depth
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if analysis_results:
|
| 140 |
+
st.session_state.analysis_results = analysis_results
|
| 141 |
+
st.session_state.recommendations = generate_recommendations(analysis_results)
|
| 142 |
+
|
| 143 |
+
# Add to history
|
| 144 |
+
st.session_state.analysis_history.append({
|
| 145 |
+
'product_name': product_name,
|
| 146 |
+
'analysis_date': st.session_state.get('current_time', 'Unknown'),
|
| 147 |
+
'results': analysis_results
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
st.success("✅ Analiza zakończona pomyślnie!")
|
| 151 |
+
st.rerun()
|
| 152 |
+
else:
|
| 153 |
+
st.error("❌ Wystąpił błąd podczas analizy. Spróbuj ponownie.")
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
st.error(f"❌ Błąd podczas analizy: {str(e)}")
|
| 157 |
+
|
| 158 |
+
# Display results
|
| 159 |
+
if st.session_state.analysis_results:
|
| 160 |
+
st.markdown("---")
|
| 161 |
+
st.markdown("## 📊 Wyniki analizy")
|
| 162 |
+
|
| 163 |
+
# Display analysis results
|
| 164 |
+
display_results(st.session_state.analysis_results, product_name)
|
| 165 |
+
|
| 166 |
+
# Display recommendations
|
| 167 |
+
if st.session_state.recommendations:
|
| 168 |
+
st.markdown("## 💡 Rekomendacje")
|
| 169 |
+
display_recommendations(st.session_state.recommendations)
|
| 170 |
+
|
| 171 |
+
# Export options
|
| 172 |
+
col1, col2, col3 = st.columns(3)
|
| 173 |
+
with col1:
|
| 174 |
+
if st.button("📄 Eksportuj do PDF"):
|
| 175 |
+
st.info("🚧 Funkcja w przygotowaniu")
|
| 176 |
+
with col2:
|
| 177 |
+
if st.button("📧 Wyślij email"):
|
| 178 |
+
st.info("🚧 Funkcja w przygotowaniu")
|
| 179 |
+
with col3:
|
| 180 |
+
if st.button("🔄 Nowa analiza"):
|
| 181 |
+
st.session_state.analysis_results = None
|
| 182 |
+
st.session_state.recommendations = None
|
| 183 |
+
st.rerun()
|
| 184 |
+
|
| 185 |
+
# Sidebar with history and info
|
| 186 |
+
with st.sidebar:
|
| 187 |
+
st.markdown("## 📈 Historia analiz")
|
| 188 |
+
if st.session_state.analysis_history:
|
| 189 |
+
for i, analysis in enumerate(reversed(st.session_state.analysis_history[-5:])):
|
| 190 |
+
with st.expander(f"{analysis['product_name']} ({analysis['analysis_date']})"):
|
| 191 |
+
score = analysis['results'].get('overall_score', 'N/A')
|
| 192 |
+
st.write(f"Ocena: {score}/10")
|
| 193 |
+
if analysis['results'].get('product_found'):
|
| 194 |
+
st.write("✅ Produkt znaleziony")
|
| 195 |
+
else:
|
| 196 |
+
st.write("❌ Produkt nie znaleziony")
|
| 197 |
+
else:
|
| 198 |
+
st.write("Brak historii analiz")
|
| 199 |
+
|
| 200 |
+
st.markdown("---")
|
| 201 |
+
st.markdown("## ℹ️ Informacje")
|
| 202 |
+
st.markdown("""
|
| 203 |
+
**Jak używać:**
|
| 204 |
+
1. Wgraj zdjęcie półki
|
| 205 |
+
2. Wpisz nazwę produktu
|
| 206 |
+
3. Kliknij "Analizuj"
|
| 207 |
+
4. Otrzymaj rekomendacje
|
| 208 |
+
|
| 209 |
+
**Wskazówki:**
|
| 210 |
+
- Rób zdjęcia z dobrem oświetleniu
|
| 211 |
+
- Upewnij się, że produkty są widoczne
|
| 212 |
+
- Używaj konkretnych nazw produktów
|
| 213 |
+
""")
|
| 214 |
+
|
| 215 |
+
st.markdown("---")
|
| 216 |
+
st.markdown("**Powered by OpenAI GPT-4 Vision**")
|
| 217 |
+
|
| 218 |
+
if __name__ == "__main__":
|
| 219 |
+
main()
|
config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# OpenAI Configuration
|
| 5 |
+
def get_openai_api_key():
|
| 6 |
+
"""Get OpenAI API key from user input in session state"""
|
| 7 |
+
api_key = st.session_state.get('openai_api_key', '')
|
| 8 |
+
if not api_key:
|
| 9 |
+
return None
|
| 10 |
+
return api_key
|
| 11 |
+
|
| 12 |
+
# Application Configuration
|
| 13 |
+
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 14 |
+
SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'webp']
|
| 15 |
+
AI_MODEL = "gpt-4-vision-preview"
|
| 16 |
+
|
| 17 |
+
# Analysis Settings
|
| 18 |
+
ANALYSIS_TIMEOUT = 60 # seconds
|
| 19 |
+
MAX_RETRY_ATTEMPTS = 3
|
| 20 |
+
|
| 21 |
+
# UI Configuration
|
| 22 |
+
PRIMARY_COLOR = "#FF6B6B"
|
| 23 |
+
BACKGROUND_COLOR = "#FFFFFF"
|
| 24 |
+
SECONDARY_BACKGROUND_COLOR = "#F0F2F6"
|
| 25 |
+
TEXT_COLOR = "#262730"
|
| 26 |
+
|
| 27 |
+
# Prompt Templates
|
| 28 |
+
ANALYSIS_PROMPT_TEMPLATE = """
|
| 29 |
+
Jesteś ekspertem w analizie ekspozycji produktów w sklepach detalicznych.
|
| 30 |
+
Przeanalizuj to zdjęcie półki pod kątem obecności produktu: "{product_name}".
|
| 31 |
+
|
| 32 |
+
Zwróć wyniki w formacie JSON z następującymi polami:
|
| 33 |
+
{{
|
| 34 |
+
"product_found": true/false,
|
| 35 |
+
"facing_count": liczba_widocznych_sztuk,
|
| 36 |
+
"shelf_position": "top"/"middle"/"bottom",
|
| 37 |
+
"price_visible": true/false,
|
| 38 |
+
"product_condition": "good"/"dusty"/"damaged",
|
| 39 |
+
"overall_score": ocena_1_10,
|
| 40 |
+
"confidence": pewność_0_1,
|
| 41 |
+
"description": "szczegółowy_opis_sytuacji",
|
| 42 |
+
"competitors_nearby": ["lista_konkurencyjnych_produktów"],
|
| 43 |
+
"shelf_share": procent_zajętości_półki
|
| 44 |
+
}}
|
| 45 |
+
|
| 46 |
+
Poziom analizy: {analysis_depth}
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
RECOMMENDATIONS_RULES = {
|
| 50 |
+
"position": {
|
| 51 |
+
"eye_level": "Przenieś produkty na poziom oczu (zwiększa sprzedaż o 30%)",
|
| 52 |
+
"visibility": "Popraw widoczność produktów"
|
| 53 |
+
},
|
| 54 |
+
"quantity": {
|
| 55 |
+
"increase_facings": "Dodaj dodatkowe facings",
|
| 56 |
+
"restock": "Uzupełnij brakujące produkty"
|
| 57 |
+
},
|
| 58 |
+
"condition": {
|
| 59 |
+
"cleaning": "Wyczyść produkty i opakowania",
|
| 60 |
+
"replace_damaged": "Wymień uszkodzone produkty"
|
| 61 |
+
},
|
| 62 |
+
"pricing": {
|
| 63 |
+
"add_price": "Dodaj widoczną cenę",
|
| 64 |
+
"update_price": "Zaktualizuj cenę"
|
| 65 |
+
}
|
| 66 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.28.0
|
| 2 |
+
Pillow>=10.0.0
|
| 3 |
+
openai>=1.3.0
|
| 4 |
+
pandas>=2.0.0
|
| 5 |
+
numpy>=1.24.0
|
| 6 |
+
requests>=2.31.0
|
utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package for Shelf Photo Analyzer
|
utils/ai_analyzer.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import openai
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import json
|
| 4 |
+
from typing import Dict, Optional
|
| 5 |
+
from config import get_openai_api_key, ANALYSIS_PROMPT_TEMPLATE, AI_MODEL
|
| 6 |
+
|
| 7 |
+
def analyze_shelf_image(image_base64: str, product_name: str, analysis_depth: str = "Podstawowy") -> Optional[Dict]:
|
| 8 |
+
"""
|
| 9 |
+
Analyze shelf image using OpenAI GPT-4 Vision API
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
image_base64: Base64 encoded image
|
| 13 |
+
product_name: Name of product to search for
|
| 14 |
+
analysis_depth: Level of analysis detail
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Dictionary with analysis results or None if failed
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
# Get API key and validate
|
| 21 |
+
api_key = get_openai_api_key()
|
| 22 |
+
if not api_key:
|
| 23 |
+
st.error("⚠️ OpenAI API key not configured!")
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
# Initialize OpenAI client
|
| 27 |
+
client = openai.OpenAI(api_key=api_key)
|
| 28 |
+
|
| 29 |
+
# Prepare the prompt
|
| 30 |
+
prompt = ANALYSIS_PROMPT_TEMPLATE.format(
|
| 31 |
+
product_name=product_name,
|
| 32 |
+
analysis_depth=analysis_depth
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Make API call
|
| 36 |
+
response = client.chat.completions.create(
|
| 37 |
+
model=AI_MODEL,
|
| 38 |
+
messages=[
|
| 39 |
+
{
|
| 40 |
+
"role": "user",
|
| 41 |
+
"content": [
|
| 42 |
+
{
|
| 43 |
+
"type": "text",
|
| 44 |
+
"text": prompt
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"type": "image_url",
|
| 48 |
+
"image_url": {
|
| 49 |
+
"url": f"data:image/jpeg;base64,{image_base64}",
|
| 50 |
+
"detail": "high"
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
]
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
max_tokens=1500,
|
| 57 |
+
temperature=0.1
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Parse response
|
| 61 |
+
content = response.choices[0].message.content
|
| 62 |
+
|
| 63 |
+
# Try to extract JSON from response
|
| 64 |
+
try:
|
| 65 |
+
# Find JSON in the response
|
| 66 |
+
start_idx = content.find('{')
|
| 67 |
+
end_idx = content.rfind('}') + 1
|
| 68 |
+
|
| 69 |
+
if start_idx != -1 and end_idx != -1:
|
| 70 |
+
json_str = content[start_idx:end_idx]
|
| 71 |
+
analysis_results = json.loads(json_str)
|
| 72 |
+
|
| 73 |
+
# Validate required fields
|
| 74 |
+
required_fields = ['product_found', 'overall_score', 'confidence']
|
| 75 |
+
for field in required_fields:
|
| 76 |
+
if field not in analysis_results:
|
| 77 |
+
st.warning(f"Missing required field: {field}")
|
| 78 |
+
analysis_results[field] = get_default_value(field)
|
| 79 |
+
|
| 80 |
+
# Ensure proper data types
|
| 81 |
+
analysis_results = normalize_analysis_results(analysis_results)
|
| 82 |
+
|
| 83 |
+
return analysis_results
|
| 84 |
+
else:
|
| 85 |
+
st.error("Could not find JSON in AI response")
|
| 86 |
+
return create_fallback_result(product_name, content)
|
| 87 |
+
|
| 88 |
+
except json.JSONDecodeError as e:
|
| 89 |
+
st.error(f"Failed to parse AI response as JSON: {str(e)}")
|
| 90 |
+
return create_fallback_result(product_name, content)
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
st.error(f"Error during AI analysis: {str(e)}")
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
def normalize_analysis_results(results: Dict) -> Dict:
|
| 97 |
+
"""Normalize analysis results to ensure proper data types"""
|
| 98 |
+
try:
|
| 99 |
+
# Ensure boolean fields
|
| 100 |
+
bool_fields = ['product_found', 'price_visible']
|
| 101 |
+
for field in bool_fields:
|
| 102 |
+
if field in results:
|
| 103 |
+
if isinstance(results[field], str):
|
| 104 |
+
results[field] = results[field].lower() in ['true', '1', 'yes', 'tak']
|
| 105 |
+
else:
|
| 106 |
+
results[field] = bool(results[field])
|
| 107 |
+
|
| 108 |
+
# Ensure numeric fields
|
| 109 |
+
if 'overall_score' in results:
|
| 110 |
+
try:
|
| 111 |
+
results['overall_score'] = max(1, min(10, int(float(results['overall_score']))))
|
| 112 |
+
except:
|
| 113 |
+
results['overall_score'] = 5
|
| 114 |
+
|
| 115 |
+
if 'confidence' in results:
|
| 116 |
+
try:
|
| 117 |
+
results['confidence'] = max(0.0, min(1.0, float(results['confidence'])))
|
| 118 |
+
except:
|
| 119 |
+
results['confidence'] = 0.5
|
| 120 |
+
|
| 121 |
+
if 'facing_count' in results:
|
| 122 |
+
try:
|
| 123 |
+
results['facing_count'] = max(0, int(results['facing_count']))
|
| 124 |
+
except:
|
| 125 |
+
results['facing_count'] = 0
|
| 126 |
+
|
| 127 |
+
if 'shelf_share' in results:
|
| 128 |
+
try:
|
| 129 |
+
results['shelf_share'] = max(0, min(100, float(results['shelf_share'])))
|
| 130 |
+
except:
|
| 131 |
+
results['shelf_share'] = 0
|
| 132 |
+
|
| 133 |
+
# Ensure string fields
|
| 134 |
+
string_fields = ['shelf_position', 'product_condition', 'description']
|
| 135 |
+
for field in string_fields:
|
| 136 |
+
if field in results and not isinstance(results[field], str):
|
| 137 |
+
results[field] = str(results[field])
|
| 138 |
+
|
| 139 |
+
# Ensure list fields
|
| 140 |
+
if 'competitors_nearby' in results and not isinstance(results['competitors_nearby'], list):
|
| 141 |
+
results['competitors_nearby'] = []
|
| 142 |
+
|
| 143 |
+
return results
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
st.warning(f"Error normalizing results: {str(e)}")
|
| 147 |
+
return results
|
| 148 |
+
|
| 149 |
+
def get_default_value(field: str):
|
| 150 |
+
"""Get default value for missing field"""
|
| 151 |
+
defaults = {
|
| 152 |
+
'product_found': False,
|
| 153 |
+
'facing_count': 0,
|
| 154 |
+
'shelf_position': 'unknown',
|
| 155 |
+
'price_visible': False,
|
| 156 |
+
'product_condition': 'unknown',
|
| 157 |
+
'overall_score': 5,
|
| 158 |
+
'confidence': 0.5,
|
| 159 |
+
'description': 'Analysis incomplete',
|
| 160 |
+
'competitors_nearby': [],
|
| 161 |
+
'shelf_share': 0
|
| 162 |
+
}
|
| 163 |
+
return defaults.get(field, None)
|
| 164 |
+
|
| 165 |
+
def create_fallback_result(product_name: str, ai_response: str) -> Dict:
|
| 166 |
+
"""Create fallback result when JSON parsing fails"""
|
| 167 |
+
# Try to extract basic information from text response
|
| 168 |
+
product_found = any(word in ai_response.lower() for word in ['found', 'visible', 'present', 'znaleziono', 'widoczny'])
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
'product_found': product_found,
|
| 172 |
+
'facing_count': 1 if product_found else 0,
|
| 173 |
+
'shelf_position': 'unknown',
|
| 174 |
+
'price_visible': 'cena' in ai_response.lower() or 'price' in ai_response.lower(),
|
| 175 |
+
'product_condition': 'good',
|
| 176 |
+
'overall_score': 6 if product_found else 3,
|
| 177 |
+
'confidence': 0.6,
|
| 178 |
+
'description': f"Analysis of {product_name}: {ai_response[:200]}...",
|
| 179 |
+
'competitors_nearby': [],
|
| 180 |
+
'shelf_share': 10 if product_found else 0
|
| 181 |
+
}
|
utils/recommendations.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, List
|
| 2 |
+
from config import RECOMMENDATIONS_RULES
|
| 3 |
+
|
| 4 |
+
def generate_recommendations(analysis_results: Dict) -> List[Dict]:
|
| 5 |
+
"""
|
| 6 |
+
Generate actionable recommendations based on analysis results
|
| 7 |
+
|
| 8 |
+
Args:
|
| 9 |
+
analysis_results: Dictionary with analysis results from AI
|
| 10 |
+
|
| 11 |
+
Returns:
|
| 12 |
+
List of recommendation dictionaries
|
| 13 |
+
"""
|
| 14 |
+
recommendations = []
|
| 15 |
+
|
| 16 |
+
if not analysis_results:
|
| 17 |
+
return recommendations
|
| 18 |
+
|
| 19 |
+
# Product not found recommendations
|
| 20 |
+
if not analysis_results.get('product_found', False):
|
| 21 |
+
recommendations.append({
|
| 22 |
+
'type': 'critical',
|
| 23 |
+
'priority': 1,
|
| 24 |
+
'title': '🚨 Produkt nie znaleziony',
|
| 25 |
+
'description': 'Produkt nie jest widoczny na półce lub jest niedostępny.',
|
| 26 |
+
'action': 'Uzupełnij asortyment lub sprawdź lokalizację produktu',
|
| 27 |
+
'estimated_impact': '+100% dostępności',
|
| 28 |
+
'time_to_fix': '5-15 minut',
|
| 29 |
+
'difficulty': 'Łatwe'
|
| 30 |
+
})
|
| 31 |
+
return recommendations
|
| 32 |
+
|
| 33 |
+
# Position recommendations
|
| 34 |
+
shelf_position = analysis_results.get('shelf_position', '').lower()
|
| 35 |
+
if shelf_position in ['bottom', 'dół', 'dolna']:
|
| 36 |
+
recommendations.append({
|
| 37 |
+
'type': 'position',
|
| 38 |
+
'priority': 2,
|
| 39 |
+
'title': '📈 Przenieś na poziom oczu',
|
| 40 |
+
'description': 'Produkt znajduje się na dolnej półce, co znacznie zmniejsza widoczność.',
|
| 41 |
+
'action': 'Przenieś produkty na poziom oczu (120-160cm)',
|
| 42 |
+
'estimated_impact': '+30% sprzedaży',
|
| 43 |
+
'time_to_fix': '2-3 minuty',
|
| 44 |
+
'difficulty': 'Łatwe'
|
| 45 |
+
})
|
| 46 |
+
elif shelf_position in ['top', 'góra', 'górna']:
|
| 47 |
+
recommendations.append({
|
| 48 |
+
'type': 'position',
|
| 49 |
+
'priority': 3,
|
| 50 |
+
'title': '👁️ Popraw widoczność',
|
| 51 |
+
'description': 'Produkt na górnej półce - rozważ przeniesienie lub dodatkowe oznakowanie.',
|
| 52 |
+
'action': 'Przenieś na środkową półkę lub dodaj shelf talker',
|
| 53 |
+
'estimated_impact': '+20% widoczności',
|
| 54 |
+
'time_to_fix': '2-3 minuty',
|
| 55 |
+
'difficulty': 'Łatwe'
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
# Facing count recommendations
|
| 59 |
+
facing_count = analysis_results.get('facing_count', 0)
|
| 60 |
+
if facing_count == 1:
|
| 61 |
+
recommendations.append({
|
| 62 |
+
'type': 'quantity',
|
| 63 |
+
'priority': 3,
|
| 64 |
+
'title': '➕ Zwiększ liczbę facings',
|
| 65 |
+
'description': 'Tylko jeden facing - zwiększenie do 2-3 poprawia widoczność.',
|
| 66 |
+
'action': 'Dodaj 1-2 dodatkowe facings',
|
| 67 |
+
'estimated_impact': '+15% widoczności',
|
| 68 |
+
'time_to_fix': '1-2 minuty',
|
| 69 |
+
'difficulty': 'Bardzo łatwe'
|
| 70 |
+
})
|
| 71 |
+
elif facing_count == 0:
|
| 72 |
+
recommendations.append({
|
| 73 |
+
'type': 'quantity',
|
| 74 |
+
'priority': 1,
|
| 75 |
+
'title': '🔄 Uzupełnij produkty',
|
| 76 |
+
'description': 'Brak produktów na półce.',
|
| 77 |
+
'action': 'Uzupełnij asortyment z magazynu',
|
| 78 |
+
'estimated_impact': '+100% dostępności',
|
| 79 |
+
'time_to_fix': '5-10 minut',
|
| 80 |
+
'difficulty': 'Łatwe'
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
# Price visibility recommendations
|
| 84 |
+
if not analysis_results.get('price_visible', False):
|
| 85 |
+
recommendations.append({
|
| 86 |
+
'type': 'pricing',
|
| 87 |
+
'priority': 2,
|
| 88 |
+
'title': '💰 Dodaj widoczną cenę',
|
| 89 |
+
'description': 'Brak widocznej ceny może zniechęcać klientów.',
|
| 90 |
+
'action': 'Dodaj lub popraw czytelność cenówki',
|
| 91 |
+
'estimated_impact': '+10% konwersji',
|
| 92 |
+
'time_to_fix': '1-2 minuty',
|
| 93 |
+
'difficulty': 'Bardzo łatwe'
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
# Product condition recommendations
|
| 97 |
+
condition = analysis_results.get('product_condition', '').lower()
|
| 98 |
+
if condition in ['dusty', 'zakurzony']:
|
| 99 |
+
recommendations.append({
|
| 100 |
+
'type': 'condition',
|
| 101 |
+
'priority': 4,
|
| 102 |
+
'title': '🧹 Wyczyść produkty',
|
| 103 |
+
'description': 'Produkty wyglądają na zakurzone, co wpływa na postrzeganie jakości.',
|
| 104 |
+
'action': 'Wyczyść produkty i opakowania',
|
| 105 |
+
'estimated_impact': '+5% percepcji jakości',
|
| 106 |
+
'time_to_fix': '2-3 minuty',
|
| 107 |
+
'difficulty': 'Bardzo łatwe'
|
| 108 |
+
})
|
| 109 |
+
elif condition in ['damaged', 'uszkodzony']:
|
| 110 |
+
recommendations.append({
|
| 111 |
+
'type': 'condition',
|
| 112 |
+
'priority': 1,
|
| 113 |
+
'title': '⚠️ Wymień uszkodzone produkty',
|
| 114 |
+
'description': 'Uszkodzone produkty negatywnie wpływają na wizerunek marki.',
|
| 115 |
+
'action': 'Usuń uszkodzone produkty i zastąp nowymi',
|
| 116 |
+
'estimated_impact': '+15% percepcji marki',
|
| 117 |
+
'time_to_fix': '3-5 minut',
|
| 118 |
+
'difficulty': 'Łatwe'
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# Competition recommendations
|
| 122 |
+
competitors = analysis_results.get('competitors_nearby', [])
|
| 123 |
+
if len(competitors) > 3:
|
| 124 |
+
recommendations.append({
|
| 125 |
+
'type': 'competition',
|
| 126 |
+
'priority': 3,
|
| 127 |
+
'title': '🎯 Zwiększ wyróżnienie',
|
| 128 |
+
'description': f'Silna konkurencja w pobliżu ({len(competitors)} produktów konkurencyjnych).',
|
| 129 |
+
'action': 'Dodaj materiały POS lub zwiększ liczbę facings',
|
| 130 |
+
'estimated_impact': '+20% uwagi klientów',
|
| 131 |
+
'time_to_fix': '5-10 minut',
|
| 132 |
+
'difficulty': 'Średnie'
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
+
# Shelf share recommendations
|
| 136 |
+
shelf_share = analysis_results.get('shelf_share', 0)
|
| 137 |
+
if shelf_share < 10:
|
| 138 |
+
recommendations.append({
|
| 139 |
+
'type': 'share',
|
| 140 |
+
'priority': 2,
|
| 141 |
+
'title': '📊 Zwiększ udział w półce',
|
| 142 |
+
'description': f'Niski udział w półce ({shelf_share}%). Rozważ negocjacje z menedżerem kategorii.',
|
| 143 |
+
'action': 'Zwiększ liczbę facings lub wynegocjuj lepszą przestrzeń',
|
| 144 |
+
'estimated_impact': '+25% widoczności',
|
| 145 |
+
'time_to_fix': '10-30 minut',
|
| 146 |
+
'difficulty': 'Trudne'
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# Overall score recommendations
|
| 150 |
+
overall_score = analysis_results.get('overall_score', 5)
|
| 151 |
+
if overall_score < 4:
|
| 152 |
+
recommendations.append({
|
| 153 |
+
'type': 'general',
|
| 154 |
+
'priority': 1,
|
| 155 |
+
'title': '🚀 Kompleksowa poprawa',
|
| 156 |
+
'description': 'Niska ogólna ocena ekspozycji wymaga kompleksowej poprawy.',
|
| 157 |
+
'action': 'Zastosuj wszystkie powyższe rekomendacje w kolejności priorytetów',
|
| 158 |
+
'estimated_impact': '+50% ogólnej efektywności',
|
| 159 |
+
'time_to_fix': '15-30 minut',
|
| 160 |
+
'difficulty': 'Średnie'
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
# Sort recommendations by priority
|
| 164 |
+
recommendations.sort(key=lambda x: x['priority'])
|
| 165 |
+
|
| 166 |
+
return recommendations
|
| 167 |
+
|
| 168 |
+
def calculate_total_impact(recommendations: List[Dict]) -> Dict:
|
| 169 |
+
"""
|
| 170 |
+
Calculate estimated total impact of all recommendations
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
recommendations: List of recommendation dictionaries
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Dictionary with total impact estimates
|
| 177 |
+
"""
|
| 178 |
+
total_time = 0
|
| 179 |
+
impact_categories = {
|
| 180 |
+
'sprzedaż': [],
|
| 181 |
+
'widoczność': [],
|
| 182 |
+
'dostępność': [],
|
| 183 |
+
'jakość': []
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
for rec in recommendations:
|
| 187 |
+
# Extract time (assuming format like "2-3 minuty")
|
| 188 |
+
time_str = rec.get('time_to_fix', '0')
|
| 189 |
+
try:
|
| 190 |
+
# Extract first number from time string
|
| 191 |
+
time_parts = time_str.split('-')
|
| 192 |
+
if time_parts:
|
| 193 |
+
total_time += int(''.join(filter(str.isdigit, time_parts[0])))
|
| 194 |
+
except:
|
| 195 |
+
total_time += 5 # Default 5 minutes
|
| 196 |
+
|
| 197 |
+
# Categorize impact
|
| 198 |
+
impact = rec.get('estimated_impact', '')
|
| 199 |
+
if 'sprzedaż' in impact.lower():
|
| 200 |
+
impact_categories['sprzedaż'].append(impact)
|
| 201 |
+
elif 'widoczność' in impact.lower():
|
| 202 |
+
impact_categories['widoczność'].append(impact)
|
| 203 |
+
elif 'dostępność' in impact.lower():
|
| 204 |
+
impact_categories['dostępność'].append(impact)
|
| 205 |
+
elif any(word in impact.lower() for word in ['jakość', 'percepcj']):
|
| 206 |
+
impact_categories['jakość'].append(impact)
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
'total_time_minutes': total_time,
|
| 210 |
+
'impact_categories': impact_categories,
|
| 211 |
+
'total_recommendations': len(recommendations),
|
| 212 |
+
'high_priority_count': len([r for r in recommendations if r['priority'] <= 2])
|
| 213 |
+
}
|
utils/ui_components.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
from utils.recommendations import calculate_total_impact
|
| 4 |
+
|
| 5 |
+
def display_results(analysis_results: Dict, product_name: str):
|
| 6 |
+
"""Display analysis results in a formatted way"""
|
| 7 |
+
|
| 8 |
+
col1, col2, col3 = st.columns(3)
|
| 9 |
+
|
| 10 |
+
with col1:
|
| 11 |
+
# Overall score
|
| 12 |
+
score = analysis_results.get('overall_score', 5)
|
| 13 |
+
if score >= 8:
|
| 14 |
+
score_color = "🟢"
|
| 15 |
+
score_text = "Excellent"
|
| 16 |
+
elif score >= 6:
|
| 17 |
+
score_color = "🟡"
|
| 18 |
+
score_text = "Good"
|
| 19 |
+
else:
|
| 20 |
+
score_color = "🔴"
|
| 21 |
+
score_text = "Needs Improvement"
|
| 22 |
+
|
| 23 |
+
st.metric(
|
| 24 |
+
label="Overall Score",
|
| 25 |
+
value=f"{score}/10",
|
| 26 |
+
delta=score_text,
|
| 27 |
+
help="Overall assessment of product placement"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
with col2:
|
| 31 |
+
# Confidence
|
| 32 |
+
confidence = analysis_results.get('confidence', 0.5)
|
| 33 |
+
confidence_pct = int(confidence * 100)
|
| 34 |
+
st.metric(
|
| 35 |
+
label="AI Confidence",
|
| 36 |
+
value=f"{confidence_pct}%",
|
| 37 |
+
help="How confident the AI is in this analysis"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
with col3:
|
| 41 |
+
# Product found status
|
| 42 |
+
found = analysis_results.get('product_found', False)
|
| 43 |
+
status = "✅ Found" if found else "❌ Not Found"
|
| 44 |
+
st.metric(
|
| 45 |
+
label="Product Status",
|
| 46 |
+
value=status,
|
| 47 |
+
help="Whether the product was detected on the shelf"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
st.markdown("---")
|
| 51 |
+
|
| 52 |
+
# Detailed information
|
| 53 |
+
col1, col2 = st.columns(2)
|
| 54 |
+
|
| 55 |
+
with col1:
|
| 56 |
+
st.markdown("### 📊 Product Details")
|
| 57 |
+
|
| 58 |
+
# Facing count
|
| 59 |
+
facing_count = analysis_results.get('facing_count', 0)
|
| 60 |
+
st.info(f"**Facings:** {facing_count} visible units")
|
| 61 |
+
|
| 62 |
+
# Shelf position
|
| 63 |
+
position = analysis_results.get('shelf_position', 'Unknown')
|
| 64 |
+
position_emoji = {
|
| 65 |
+
'top': '⬆️',
|
| 66 |
+
'middle': '➡️',
|
| 67 |
+
'bottom': '⬇️',
|
| 68 |
+
'górna': '⬆️',
|
| 69 |
+
'środkowa': '➡️',
|
| 70 |
+
'dolna': '⬇️'
|
| 71 |
+
}.get(position.lower(), '❓')
|
| 72 |
+
st.info(f"**Position:** {position_emoji} {position.title()}")
|
| 73 |
+
|
| 74 |
+
# Price visibility
|
| 75 |
+
price_visible = analysis_results.get('price_visible', False)
|
| 76 |
+
price_status = "✅ Visible" if price_visible else "❌ Not visible"
|
| 77 |
+
st.info(f"**Price:** {price_status}")
|
| 78 |
+
|
| 79 |
+
with col2:
|
| 80 |
+
st.markdown("### 🔍 Quality Assessment")
|
| 81 |
+
|
| 82 |
+
# Product condition
|
| 83 |
+
condition = analysis_results.get('product_condition', 'Unknown')
|
| 84 |
+
condition_emoji = {
|
| 85 |
+
'good': '✅',
|
| 86 |
+
'dusty': '🧹',
|
| 87 |
+
'damaged': '⚠️',
|
| 88 |
+
'dobry': '✅',
|
| 89 |
+
'zakurzony': '🧹',
|
| 90 |
+
'uszkodzony': '⚠️'
|
| 91 |
+
}.get(condition.lower(), '❓')
|
| 92 |
+
st.info(f"**Condition:** {condition_emoji} {condition.title()}")
|
| 93 |
+
|
| 94 |
+
# Shelf share
|
| 95 |
+
shelf_share = analysis_results.get('shelf_share', 0)
|
| 96 |
+
if shelf_share > 0:
|
| 97 |
+
st.info(f"**Shelf Share:** {shelf_share}% of shelf space")
|
| 98 |
+
|
| 99 |
+
# Competitors
|
| 100 |
+
competitors = analysis_results.get('competitors_nearby', [])
|
| 101 |
+
if competitors:
|
| 102 |
+
st.info(f"**Nearby Competitors:** {len(competitors)} products")
|
| 103 |
+
|
| 104 |
+
# Description
|
| 105 |
+
description = analysis_results.get('description', '')
|
| 106 |
+
if description:
|
| 107 |
+
st.markdown("### 📝 Analysis Details")
|
| 108 |
+
st.markdown(f"> {description}")
|
| 109 |
+
|
| 110 |
+
def display_recommendations(recommendations: List[Dict]):
|
| 111 |
+
"""Display recommendations in a formatted way"""
|
| 112 |
+
|
| 113 |
+
if not recommendations:
|
| 114 |
+
st.info("No specific recommendations generated.")
|
| 115 |
+
return
|
| 116 |
+
|
| 117 |
+
# Calculate total impact
|
| 118 |
+
impact_summary = calculate_total_impact(recommendations)
|
| 119 |
+
|
| 120 |
+
# Display summary metrics
|
| 121 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 122 |
+
|
| 123 |
+
with col1:
|
| 124 |
+
st.metric(
|
| 125 |
+
"Total Recommendations",
|
| 126 |
+
impact_summary['total_recommendations'],
|
| 127 |
+
help="Number of actionable recommendations"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
with col2:
|
| 131 |
+
st.metric(
|
| 132 |
+
"High Priority",
|
| 133 |
+
impact_summary['high_priority_count'],
|
| 134 |
+
help="Critical issues requiring immediate attention"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
with col3:
|
| 138 |
+
st.metric(
|
| 139 |
+
"Estimated Time",
|
| 140 |
+
f"{impact_summary['total_time_minutes']} min",
|
| 141 |
+
help="Total time needed to implement all changes"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
with col4:
|
| 145 |
+
# Calculate difficulty distribution
|
| 146 |
+
difficulties = [r.get('difficulty', 'Unknown') for r in recommendations]
|
| 147 |
+
easy_count = difficulties.count('Bardzo łatwe') + difficulties.count('Łatwe')
|
| 148 |
+
difficulty_text = f"{easy_count}/{len(recommendations)} Easy"
|
| 149 |
+
st.metric(
|
| 150 |
+
"Difficulty",
|
| 151 |
+
difficulty_text,
|
| 152 |
+
help="How many recommendations are easy to implement"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
st.markdown("---")
|
| 156 |
+
|
| 157 |
+
# Group recommendations by priority
|
| 158 |
+
high_priority = [r for r in recommendations if r.get('priority', 5) <= 2]
|
| 159 |
+
medium_priority = [r for r in recommendations if r.get('priority', 5) == 3]
|
| 160 |
+
low_priority = [r for r in recommendations if r.get('priority', 5) >= 4]
|
| 161 |
+
|
| 162 |
+
# Display high priority recommendations
|
| 163 |
+
if high_priority:
|
| 164 |
+
st.markdown("### 🚨 High Priority (Do First)")
|
| 165 |
+
for rec in high_priority:
|
| 166 |
+
display_recommendation_card(rec)
|
| 167 |
+
|
| 168 |
+
# Display medium priority recommendations
|
| 169 |
+
if medium_priority:
|
| 170 |
+
st.markdown("### ⚡ Medium Priority")
|
| 171 |
+
for rec in medium_priority:
|
| 172 |
+
display_recommendation_card(rec)
|
| 173 |
+
|
| 174 |
+
# Display low priority recommendations
|
| 175 |
+
if low_priority:
|
| 176 |
+
st.markdown("### 💡 Additional Improvements")
|
| 177 |
+
for rec in low_priority:
|
| 178 |
+
display_recommendation_card(rec)
|
| 179 |
+
|
| 180 |
+
def display_recommendation_card(recommendation: Dict):
|
| 181 |
+
"""Display a single recommendation as a card"""
|
| 182 |
+
|
| 183 |
+
# Priority styling
|
| 184 |
+
priority = recommendation.get('priority', 5)
|
| 185 |
+
if priority <= 2:
|
| 186 |
+
border_color = "#ff4b4b" # Red
|
| 187 |
+
elif priority == 3:
|
| 188 |
+
border_color = "#ffa500" # Orange
|
| 189 |
+
else:
|
| 190 |
+
border_color = "#1f77b4" # Blue
|
| 191 |
+
|
| 192 |
+
# Card styling
|
| 193 |
+
st.markdown(f"""
|
| 194 |
+
<div style="
|
| 195 |
+
border: 2px solid {border_color};
|
| 196 |
+
border-radius: 10px;
|
| 197 |
+
padding: 15px;
|
| 198 |
+
margin: 10px 0;
|
| 199 |
+
background-color: white;
|
| 200 |
+
">
|
| 201 |
+
</div>
|
| 202 |
+
""", unsafe_allow_html=True)
|
| 203 |
+
|
| 204 |
+
with st.container():
|
| 205 |
+
col1, col2 = st.columns([3, 1])
|
| 206 |
+
|
| 207 |
+
with col1:
|
| 208 |
+
# Title and description
|
| 209 |
+
title = recommendation.get('title', 'Recommendation')
|
| 210 |
+
st.markdown(f"**{title}**")
|
| 211 |
+
|
| 212 |
+
description = recommendation.get('description', '')
|
| 213 |
+
if description:
|
| 214 |
+
st.markdown(f"*{description}*")
|
| 215 |
+
|
| 216 |
+
# Action
|
| 217 |
+
action = recommendation.get('action', '')
|
| 218 |
+
if action:
|
| 219 |
+
st.markdown(f"**Action:** {action}")
|
| 220 |
+
|
| 221 |
+
with col2:
|
| 222 |
+
# Metrics
|
| 223 |
+
impact = recommendation.get('estimated_impact', '')
|
| 224 |
+
if impact:
|
| 225 |
+
st.markdown(f"📈 **{impact}**")
|
| 226 |
+
|
| 227 |
+
time_to_fix = recommendation.get('time_to_fix', '')
|
| 228 |
+
if time_to_fix:
|
| 229 |
+
st.markdown(f"⏱️ {time_to_fix}")
|
| 230 |
+
|
| 231 |
+
difficulty = recommendation.get('difficulty', '')
|
| 232 |
+
if difficulty:
|
| 233 |
+
difficulty_emoji = {
|
| 234 |
+
'Bardzo łatwe': '🟢',
|
| 235 |
+
'Łatwe': '🟡',
|
| 236 |
+
'Średnie': '🟠',
|
| 237 |
+
'Trudne': '🔴'
|
| 238 |
+
}.get(difficulty, '⚪')
|
| 239 |
+
st.markdown(f"{difficulty_emoji} {difficulty}")
|
| 240 |
+
|
| 241 |
+
def display_analysis_history(history: List[Dict]):
|
| 242 |
+
"""Display analysis history"""
|
| 243 |
+
|
| 244 |
+
if not history:
|
| 245 |
+
st.info("No analysis history available.")
|
| 246 |
+
return
|
| 247 |
+
|
| 248 |
+
for i, analysis in enumerate(reversed(history[-10:])): # Show last 10
|
| 249 |
+
with st.expander(
|
| 250 |
+
f"{analysis.get('product_name', 'Unknown')} - {analysis.get('analysis_date', 'Unknown')}"
|
| 251 |
+
):
|
| 252 |
+
results = analysis.get('results', {})
|
| 253 |
+
|
| 254 |
+
col1, col2, col3 = st.columns(3)
|
| 255 |
+
|
| 256 |
+
with col1:
|
| 257 |
+
score = results.get('overall_score', 'N/A')
|
| 258 |
+
st.metric("Score", f"{score}/10")
|
| 259 |
+
|
| 260 |
+
with col2:
|
| 261 |
+
found = results.get('product_found', False)
|
| 262 |
+
status = "Found" if found else "Not Found"
|
| 263 |
+
st.metric("Status", status)
|
| 264 |
+
|
| 265 |
+
with col3:
|
| 266 |
+
facings = results.get('facing_count', 0)
|
| 267 |
+
st.metric("Facings", facings)
|
| 268 |
+
|
| 269 |
+
description = results.get('description', '')
|
| 270 |
+
if description:
|
| 271 |
+
st.markdown(f"**Description:** {description[:200]}...")
|
| 272 |
+
|
| 273 |
+
def create_export_summary(analysis_results: Dict, recommendations: List[Dict], product_name: str) -> str:
|
| 274 |
+
"""Create a summary for export purposes"""
|
| 275 |
+
|
| 276 |
+
summary = f"""
|
| 277 |
+
# Shelf Analysis Report - {product_name}
|
| 278 |
+
|
| 279 |
+
## Analysis Results
|
| 280 |
+
- **Overall Score:** {analysis_results.get('overall_score', 'N/A')}/10
|
| 281 |
+
- **Product Found:** {'Yes' if analysis_results.get('product_found') else 'No'}
|
| 282 |
+
- **Facing Count:** {analysis_results.get('facing_count', 0)}
|
| 283 |
+
- **Shelf Position:** {analysis_results.get('shelf_position', 'Unknown')}
|
| 284 |
+
- **Price Visible:** {'Yes' if analysis_results.get('price_visible') else 'No'}
|
| 285 |
+
- **Product Condition:** {analysis_results.get('product_condition', 'Unknown')}
|
| 286 |
+
- **AI Confidence:** {int(analysis_results.get('confidence', 0.5) * 100)}%
|
| 287 |
+
|
| 288 |
+
## Recommendations ({len(recommendations)})
|
| 289 |
+
"""
|
| 290 |
+
|
| 291 |
+
for i, rec in enumerate(recommendations, 1):
|
| 292 |
+
summary += f"""
|
| 293 |
+
### {i}. {rec.get('title', 'Recommendation')}
|
| 294 |
+
- **Priority:** {rec.get('priority', 'N/A')}
|
| 295 |
+
- **Description:** {rec.get('description', '')}
|
| 296 |
+
- **Action:** {rec.get('action', '')}
|
| 297 |
+
- **Estimated Impact:** {rec.get('estimated_impact', '')}
|
| 298 |
+
- **Time to Fix:** {rec.get('time_to_fix', '')}
|
| 299 |
+
- **Difficulty:** {rec.get('difficulty', '')}
|
| 300 |
+
"""
|
| 301 |
+
|
| 302 |
+
return summary
|