upload data project klasifikasi tomat
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Tomat Classification API
|
||||||
|
|
||||||
|
API Flask untuk klasifikasi tingkat kematangan tomat menggunakan Random Forest dan Color Histogram RGB.
|
||||||
|
|
||||||
|
## Fitur
|
||||||
|
|
||||||
|
- **Preprocessing**: Resize gambar ke 256x256
|
||||||
|
- **Ekstraksi Fitur**: Color Histogram RGB (8x8x8 bins)
|
||||||
|
- **Klasifikasi**: Random Forest dengan 3 kelas
|
||||||
|
- **Format Output**: JSON dengan probabilitas dan confidence score
|
||||||
|
|
||||||
|
## Kelas Output
|
||||||
|
|
||||||
|
- `matang` - Tomat sudah matang
|
||||||
|
- `mentah` - Tomat masih mentah
|
||||||
|
- `setengah_matang` - Tomat setengah matang
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
### 1. Health Check
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"model_loaded": true,
|
||||||
|
"service": "Tomat Classification API"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Prediksi
|
||||||
|
```
|
||||||
|
POST /predict
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request**: Upload file gambar dengan key `image`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"prediction": {
|
||||||
|
"class": "matang",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"confidence_percentage": 85.0,
|
||||||
|
"probabilities": {
|
||||||
|
"matang": {"probability": 0.85, "percentage": 85.0},
|
||||||
|
"mentah": {"probability": 0.10, "percentage": 10.0},
|
||||||
|
"setengah_matang": {"probability": 0.05, "percentage": 5.0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"model_type": "RandomForest",
|
||||||
|
"features_used": 24,
|
||||||
|
"image_processed": "tomat.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Informasi Model
|
||||||
|
```
|
||||||
|
GET /info
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"model_info": {
|
||||||
|
"type": "RandomForestClassifier",
|
||||||
|
"classes": ["matang", "mentah", "setengah_matang"],
|
||||||
|
"n_features": 24,
|
||||||
|
"n_estimators": 100
|
||||||
|
},
|
||||||
|
"api_info": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"endpoints": {
|
||||||
|
"health": "/health",
|
||||||
|
"predict": "/predict (POST)",
|
||||||
|
"info": "/info"
|
||||||
|
},
|
||||||
|
"supported_formats": ["PNG", "JPG", "JPEG"],
|
||||||
|
"max_file_size": "16MB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cara Menjalankan
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
```bash
|
||||||
|
pip install flask opencv-python numpy scikit-learn joblib
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Training Model (Opsional)
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
Ini akan membuat folder `models/` dengan file model yang sudah trained.
|
||||||
|
|
||||||
|
### 3. Jalankan API
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Server akan berjalan di: `http://127.0.0.1:5000`
|
||||||
|
|
||||||
|
## Cara Testing API
|
||||||
|
|
||||||
|
### Menggunakan curl
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://127.0.0.1:5000/health
|
||||||
|
|
||||||
|
# Prediksi gambar
|
||||||
|
curl -X POST -F "image=@path/to/gambar.jpg" http://127.0.0.1:5000/predict
|
||||||
|
|
||||||
|
# Info model
|
||||||
|
curl http://127.0.0.1:5000/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menggunakan Python
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
response = requests.get('http://127.0.0.1:5000/health')
|
||||||
|
print(response.json())
|
||||||
|
|
||||||
|
# Prediksi gambar
|
||||||
|
with open('gambar_tomat.jpg', 'rb') as f:
|
||||||
|
files = {'image': f}
|
||||||
|
response = requests.post('http://127.0.0.1:5000/predict', files=files)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Struktur File
|
||||||
|
|
||||||
|
```
|
||||||
|
data_tomat/
|
||||||
|
app.py # Flask API
|
||||||
|
main.py # Training model
|
||||||
|
models/ # Folder model
|
||||||
|
tomat_classifier.pkl # Model Random Forest
|
||||||
|
label_encoder.pkl # Label encoder
|
||||||
|
metadata.pkl # Metadata model
|
||||||
|
matang/ # Folder gambar matang
|
||||||
|
mentah/ # Folder gambar mentah
|
||||||
|
setengah_matang/ # Folder gambar setengah matang
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
API mengembalikan error response dengan format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Error type",
|
||||||
|
"message": "Detailed error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
- **400**: File tidak ada, format tidak didukung, processing gagal
|
||||||
|
- **413**: File terlalu besar (>16MB)
|
||||||
|
- **500**: Internal server error
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- API akan otomatis membuat model dummy jika file model belum ada
|
||||||
|
- Untuk hasil prediksi yang akurat, jalankan `python main.py` terlebih dahulu
|
||||||
|
- File temporary akan otomatis dihapus setelah processing
|
||||||
|
- Gambar akan di-resize ke 256x256 sebelum ekstraksi fitur
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Flask 2.0+
|
||||||
|
- OpenCV 4.0+
|
||||||
|
- NumPy 1.19+
|
||||||
|
- Scikit-learn 1.0+
|
||||||
|
- Joblib 1.0+
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import joblib
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Konfigurasi logging - hanya WARNING ke atas agar tidak lambat
|
||||||
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Konfigurasi
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||||
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
|
|
||||||
|
# Path ke model
|
||||||
|
MODEL_PATH = "model_tomat.pkl"
|
||||||
|
|
||||||
|
# Global variables untuk model (di-load sekali saja)
|
||||||
|
model = None
|
||||||
|
class_names = ['matang', 'mentah', 'setengah_matang']
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
"""Check if file has allowed extension"""
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
"""Load model sekali saja saat startup"""
|
||||||
|
global model
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(MODEL_PATH):
|
||||||
|
model = joblib.load(MODEL_PATH)
|
||||||
|
print("✅ Model berhasil dimuat")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ ERROR: File model tidak ditemukan: {MODEL_PATH}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ ERROR: Gagal memuat model: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_color_histogram(image, bins=(8, 8, 8)):
|
||||||
|
"""
|
||||||
|
Ekstraksi fitur Color Histogram HSV dari gambar
|
||||||
|
Langsung dari array BGR tanpa konversi ulang yang tidak perlu
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Konversi dari BGR ke HSV
|
||||||
|
image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||||
|
|
||||||
|
# Hitung dan normalisasi histogram untuk setiap channel HSV
|
||||||
|
features = []
|
||||||
|
for i in range(3):
|
||||||
|
hist = cv2.calcHist([image_hsv], [i], None, [bins[i]], [0, 256])
|
||||||
|
hist = cv2.normalize(hist, hist).flatten()
|
||||||
|
features.append(hist)
|
||||||
|
|
||||||
|
return np.concatenate(features)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ekstraksi fitur: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def preprocess_image_from_memory(file_stream):
|
||||||
|
"""
|
||||||
|
Preprocessing gambar dari memory buffer.
|
||||||
|
Laravel sudah kirim gambar 256x256, cukup decode + ekstrak fitur saja.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Baca file stream ke memory sekaligus
|
||||||
|
file_bytes = np.frombuffer(file_stream.read(), np.uint8)
|
||||||
|
|
||||||
|
# Decode gambar
|
||||||
|
image = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
if image is None:
|
||||||
|
logger.error("Tidak dapat decode gambar dari memory")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Resize hanya jika gambar BUKAN 256x256
|
||||||
|
# (jika Laravel sudah resize, skip langkah ini)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
if h != 256 or w != 256:
|
||||||
|
image = cv2.resize(image, (256, 256), interpolation=cv2.INTER_LINEAR)
|
||||||
|
|
||||||
|
# Ekstraksi fitur histogram HSV
|
||||||
|
return extract_color_histogram(image)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error preprocessing from memory: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET'])
|
||||||
|
def home():
|
||||||
|
"""Home endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': '🍅 Tomat Classification API is running!',
|
||||||
|
'endpoints': {
|
||||||
|
'health': '/health',
|
||||||
|
'predict': '/predict (POST)',
|
||||||
|
'info': '/info'
|
||||||
|
},
|
||||||
|
'model_loaded': model is not None,
|
||||||
|
'service': 'Tomat Classification API v1.0.0',
|
||||||
|
'classes': class_names
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'status': 'healthy',
|
||||||
|
'model_loaded': model is not None,
|
||||||
|
'service': 'Tomat Classification API'
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/predict', methods=['POST'])
|
||||||
|
def predict():
|
||||||
|
"""
|
||||||
|
Endpoint prediksi kematangan tomat - Full memory processing, no disk I/O
|
||||||
|
Expected: multipart/form-data dengan file 'image'
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if model is None:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Model belum dimuat',
|
||||||
|
'message': 'Server tidak siap untuk prediksi'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
if 'image' not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'No file uploaded',
|
||||||
|
'message': 'Harap upload file gambar'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
file = request.files['image']
|
||||||
|
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'No file selected',
|
||||||
|
'message': 'Harap pilih file gambar'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Invalid file type',
|
||||||
|
'message': 'Hanya file PNG, JPG, JPEG yang diperbolehkan'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Preprocessing langsung dari memory (tanpa file temporary)
|
||||||
|
features = preprocess_image_from_memory(file)
|
||||||
|
|
||||||
|
if features is None:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Processing failed',
|
||||||
|
'message': 'Gagal memproses gambar dari memory'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Reshape + prediksi dalam satu langkah
|
||||||
|
features_reshaped = features.reshape(1, -1)
|
||||||
|
prediction = model.predict(features_reshaped)[0]
|
||||||
|
prediction_proba = model.predict_proba(features_reshaped)[0]
|
||||||
|
|
||||||
|
predicted_class = class_names[prediction]
|
||||||
|
confidence = float(np.max(prediction_proba))
|
||||||
|
|
||||||
|
# Format probabilitas
|
||||||
|
probabilities = {
|
||||||
|
class_names[i]: {
|
||||||
|
'probability': float(p),
|
||||||
|
'percentage': float(p * 100)
|
||||||
|
}
|
||||||
|
for i, p in enumerate(prediction_proba)
|
||||||
|
}
|
||||||
|
|
||||||
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'prediction': {
|
||||||
|
'class': predicted_class,
|
||||||
|
'confidence': confidence,
|
||||||
|
'confidence_percentage': confidence * 100,
|
||||||
|
'probabilities': probabilities
|
||||||
|
},
|
||||||
|
'metadata': {
|
||||||
|
'model_type': 'RandomForest',
|
||||||
|
'features_used': int(features.shape[0]),
|
||||||
|
'preprocessing': 'Memory Processing: Resize 256x256 + HSV Histogram (8x8x8)',
|
||||||
|
'processing_time_seconds': round(processing_time, 3),
|
||||||
|
'performance': 'Optimized (no temporary files)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error dalam prediksi: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Internal server error',
|
||||||
|
'message': f'Terjadi kesalahan: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/info', methods=['GET'])
|
||||||
|
def model_info():
|
||||||
|
"""Endpoint untuk informasi model"""
|
||||||
|
try:
|
||||||
|
if model is None:
|
||||||
|
return jsonify({'success': False, 'error': 'Model not loaded'}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'model_info': {
|
||||||
|
'type': type(model).__name__,
|
||||||
|
'classes': class_names,
|
||||||
|
'n_features': getattr(model, 'n_features_in_', None),
|
||||||
|
'n_estimators': getattr(model, 'n_estimators', None)
|
||||||
|
},
|
||||||
|
'api_info': {
|
||||||
|
'version': '1.0.0',
|
||||||
|
'supported_formats': ['PNG', 'JPG', 'JPEG'],
|
||||||
|
'max_file_size': '16MB',
|
||||||
|
'preprocessing': 'Memory Processing: Resize 256x256 + HSV Histogram (8x8x8)',
|
||||||
|
'performance': 'Optimized (no temporary files)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def too_large(e):
|
||||||
|
return jsonify({'success': False, 'error': 'File too large', 'message': 'Ukuran file maksimal 16MB'}), 413
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Endpoint not found',
|
||||||
|
'available_endpoints': ['/health', '/predict (POST)', '/info', '/']
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(e):
|
||||||
|
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("=" * 60)
|
||||||
|
print("🍅 TOMAT CLASSIFICATION API")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if load_model():
|
||||||
|
print("🚀 API siap digunakan")
|
||||||
|
print(f"📊 Model: {type(model).__name__}")
|
||||||
|
print(f"🎯 Kelas: {class_names}")
|
||||||
|
else:
|
||||||
|
print("❌ ERROR: Model gagal dimuat!")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print("\n📡 Endpoints:")
|
||||||
|
print(" GET / - Home/API Info")
|
||||||
|
print(" GET /health - Health check")
|
||||||
|
print(" POST /predict - Prediksi kematangan tomat")
|
||||||
|
print(" GET /info - Informasi model")
|
||||||
|
print("\n⚡ Processing: In-memory (no temporary files)")
|
||||||
|
print("🌐 Starting server on http://127.0.0.1:5000")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ✅ FIX UTAMA: debug=False agar tidak ada overhead auto-reload
|
||||||
|
# use_reloader=False mencegah model di-load 2x saat startup
|
||||||
|
app.run(host='127.0.0.1', port=5000, debug=False, use_reloader=False, threaded=True)
|
||||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
|
@ -0,0 +1,161 @@
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
from sklearn.metrics import confusion_matrix
|
||||||
|
|
||||||
|
def plot_confusion_matrix(y_true, y_pred, class_names, save_path='confusion_matrix.png'):
|
||||||
|
"""
|
||||||
|
Menampilkan confusion matrix dalam bentuk heatmap dan menyimpannya sebagai file PNG
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
y_true: label aktual (array-like)
|
||||||
|
y_pred: label prediksi (array-like)
|
||||||
|
class_names: list nama kelas (contoh: ['matang', 'mentah', 'setengah_matang'])
|
||||||
|
save_path: path untuk menyimpan file gambar (default: 'confusion_matrix.png')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
cm: confusion matrix array
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Hitung confusion matrix
|
||||||
|
cm = confusion_matrix(y_true, y_pred)
|
||||||
|
|
||||||
|
# Buat figure dengan ukuran yang lebih besar
|
||||||
|
plt.figure(figsize=(10, 8))
|
||||||
|
|
||||||
|
# Buat heatmap dengan seaborn
|
||||||
|
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
||||||
|
xticklabels=class_names, yticklabels=class_names,
|
||||||
|
cbar_kws={'label': 'Jumlah Sampel'},
|
||||||
|
square=True,
|
||||||
|
linewidths=0.5,
|
||||||
|
annot_kws={'size': 12, 'weight': 'bold'})
|
||||||
|
|
||||||
|
# Set title dan labels dengan formatting yang lebih baik
|
||||||
|
plt.title('Confusion Matrix - Klasifikasi Tingkat Kematangan Tomat',
|
||||||
|
fontsize=16, fontweight='bold', pad=20)
|
||||||
|
plt.xlabel('Kelas Prediksi', fontsize=12, fontweight='bold')
|
||||||
|
plt.ylabel('Kelas Aktual', fontsize=12, fontweight='bold')
|
||||||
|
|
||||||
|
# Rotate labels untuk better readability
|
||||||
|
plt.xticks(rotation=45, ha='right')
|
||||||
|
plt.yticks(rotation=0)
|
||||||
|
|
||||||
|
# Add text summary dengan akurasi
|
||||||
|
total_samples = len(y_true)
|
||||||
|
accuracy = np.mean(y_true == y_pred) * 100
|
||||||
|
plt.figtext(0.5, 0.02, f'Total Sampel: {total_samples} | Akurasi: {accuracy:.2f}%',
|
||||||
|
ha='center', fontsize=11, style='italic')
|
||||||
|
|
||||||
|
# Adjust layout untuk prevent label overlap
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
# Save sebagai PNG dengan high quality
|
||||||
|
plt.savefig(save_path, dpi=300, bbox_inches='tight', facecolor='white')
|
||||||
|
|
||||||
|
# Tampilkan plot
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print("CONFUSION MATRIX VISUALIZATION")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
print(f"File disimpan sebagai: {save_path}")
|
||||||
|
print(f"Ukuran gambar: 300 DPI")
|
||||||
|
print(f"Resolusi: Tinggi")
|
||||||
|
print(f"Format: PNG")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
return cm
|
||||||
|
|
||||||
|
def plot_confusion_matrix_detailed(y_true, y_pred, class_names, save_path='confusion_matrix_detailed.png'):
|
||||||
|
"""
|
||||||
|
Menampilkan confusion matrix dengan informasi detail (precision, recall, f1-score)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
y_true: label aktual
|
||||||
|
y_pred: label prediksi
|
||||||
|
class_names: list nama kelas
|
||||||
|
save_path: path untuk menyimpan file gambar
|
||||||
|
"""
|
||||||
|
from sklearn.metrics import classification_report, precision_score, recall_score, f1_score
|
||||||
|
|
||||||
|
# Hitung confusion matrix
|
||||||
|
cm = confusion_matrix(y_true, y_pred)
|
||||||
|
|
||||||
|
# Hitung metrics
|
||||||
|
precision = precision_score(y_true, y_pred, average=None)
|
||||||
|
recall = recall_score(y_true, y_pred, average=None)
|
||||||
|
f1 = f1_score(y_true, y_pred, average=None)
|
||||||
|
|
||||||
|
# Buat figure dengan 2x2 subplot
|
||||||
|
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 12))
|
||||||
|
|
||||||
|
# 1. Confusion Matrix
|
||||||
|
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
||||||
|
xticklabels=class_names, yticklabels=class_names,
|
||||||
|
ax=ax1, cbar_kws={'label': 'Jumlah Sampel'})
|
||||||
|
ax1.set_title('Confusion Matrix', fontweight='bold')
|
||||||
|
ax1.set_xlabel('Kelas Prediksi')
|
||||||
|
ax1.set_ylabel('Kelas Aktual')
|
||||||
|
|
||||||
|
# 2. Precision per kelas
|
||||||
|
bars = ax2.bar(class_names, precision, color='skyblue', alpha=0.8)
|
||||||
|
ax2.set_title('Precision per Kelas', fontweight='bold')
|
||||||
|
ax2.set_ylabel('Precision')
|
||||||
|
ax2.set_ylim(0, 1)
|
||||||
|
for bar, value in zip(bars, precision):
|
||||||
|
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
|
||||||
|
f'{value:.3f}', ha='center', va='bottom')
|
||||||
|
|
||||||
|
# 3. Recall per kelas
|
||||||
|
bars = ax3.bar(class_names, recall, color='lightgreen', alpha=0.8)
|
||||||
|
ax3.set_title('Recall per Kelas', fontweight='bold')
|
||||||
|
ax3.set_ylabel('Recall')
|
||||||
|
ax3.set_ylim(0, 1)
|
||||||
|
for bar, value in zip(bars, recall):
|
||||||
|
ax3.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
|
||||||
|
f'{value:.3f}', ha='center', va='bottom')
|
||||||
|
|
||||||
|
# 4. F1-Score per kelas
|
||||||
|
bars = ax4.bar(class_names, f1, color='salmon', alpha=0.8)
|
||||||
|
ax4.set_title('F1-Score per Kelas', fontweight='bold')
|
||||||
|
ax4.set_ylabel('F1-Score')
|
||||||
|
ax4.set_ylim(0, 1)
|
||||||
|
for bar, value in zip(bars, f1):
|
||||||
|
ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
|
||||||
|
f'{value:.3f}', ha='center', va='bottom')
|
||||||
|
|
||||||
|
# Overall title
|
||||||
|
fig.suptitle('Confusion Matrix & Metrics Detail - Klasifikasi Tomat',
|
||||||
|
fontsize=16, fontweight='bold')
|
||||||
|
|
||||||
|
# Adjust layout
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
# Save dengan high quality
|
||||||
|
plt.savefig(save_path, dpi=300, bbox_inches='tight', facecolor='white')
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
# Print classification report
|
||||||
|
print(f"\nClassification Report:")
|
||||||
|
print(classification_report(y_true, y_pred, target_names=class_names))
|
||||||
|
|
||||||
|
print(f"\nDetailed confusion matrix disimpan sebagai: {save_path}")
|
||||||
|
|
||||||
|
return cm, precision, recall, f1
|
||||||
|
|
||||||
|
# Contoh penggunaan
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Contoh data
|
||||||
|
y_true = [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2]
|
||||||
|
y_pred = [0, 1, 2, 0, 2, 2, 0, 1, 1, 0, 1, 2]
|
||||||
|
class_names = ['matang', 'mentah', 'setengah_matang']
|
||||||
|
|
||||||
|
# Plot confusion matrix sederhana
|
||||||
|
cm = plot_confusion_matrix(y_true, y_pred, class_names, 'example_confusion_matrix.png')
|
||||||
|
|
||||||
|
# Plot confusion matrix detail
|
||||||
|
cm_detail, precision, recall, f1 = plot_confusion_matrix_detailed(
|
||||||
|
y_true, y_pred, class_names, 'example_confusion_matrix_detailed.png'
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import joblib
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.ensemble import RandomForestClassifier
|
||||||
|
|
||||||
|
# Buat model Random Forest dummy
|
||||||
|
model = RandomForestClassifier(n_estimators=10, random_state=42)
|
||||||
|
|
||||||
|
# Training dengan data dummy (24 fitur untuk histogram RGB 8x8x8)
|
||||||
|
X_dummy = np.random.rand(100, 24)
|
||||||
|
y_dummy = np.random.choice([0, 1, 2], 100)
|
||||||
|
model.fit(X_dummy, y_dummy)
|
||||||
|
|
||||||
|
# Simpan model
|
||||||
|
joblib.dump(model, 'model_tomat.pkl')
|
||||||
|
print('Model dummy berhasil dibuat dan disimpan sebagai model_tomat.pkl')
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import joblib
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.ensemble import RandomForestClassifier
|
||||||
|
|
||||||
|
print("Membuat model Random Forest untuk testing...")
|
||||||
|
|
||||||
|
# Buat model Random Forest
|
||||||
|
model = RandomForestClassifier(n_estimators=10, random_state=42)
|
||||||
|
|
||||||
|
# Training dengan data dummy (24 fitur untuk histogram RGB 8x8x8)
|
||||||
|
X_dummy = np.random.rand(100, 24)
|
||||||
|
y_dummy = np.random.choice([0, 1, 2], 100)
|
||||||
|
model.fit(X_dummy, y_dummy)
|
||||||
|
|
||||||
|
# Simpan model
|
||||||
|
joblib.dump(model, 'model_tomat.pkl')
|
||||||
|
|
||||||
|
print("Model berhasil dibuat dan disimpan sebagai 'model_tomat.pkl'")
|
||||||
|
print(f"Tipe model: {type(model).__name__}")
|
||||||
|
print(f"Jumlah fitur: {model.n_features_in_}")
|
||||||
|
print(f"Jumlah kelas: {len(model.classes_)}")
|
||||||
|
print(f"Kelas: {model.classes_}")
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.ensemble import RandomForestClassifier
|
||||||
|
from sklearn.metrics import classification_report, confusion_matrix
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import seaborn as sns
|
||||||
|
import joblib
|
||||||
|
from sklearn.preprocessing import LabelEncoder
|
||||||
|
|
||||||
|
def extract_color_histogram(image, bins=(8, 8, 8)):
|
||||||
|
"""
|
||||||
|
Ekstraksi fitur Color Histogram HSV dari gambar
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
image: gambar dalam format BGR (OpenCV)
|
||||||
|
bins: jumlah bin untuk setiap channel HSV
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
features: array fitur histogram yang telah dinormalisasi
|
||||||
|
"""
|
||||||
|
# Konversi dari BGR ke HSV
|
||||||
|
image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
||||||
|
|
||||||
|
# Hitung dan normalisasi histogram untuk setiap channel HSV
|
||||||
|
features = []
|
||||||
|
for i in range(3):
|
||||||
|
hist = cv2.calcHist([image_hsv], [i], None, [bins[i]], [0, 256])
|
||||||
|
hist = cv2.normalize(hist, hist).flatten()
|
||||||
|
features.append(hist)
|
||||||
|
|
||||||
|
# Gabungkan semua histogram
|
||||||
|
features = np.concatenate(features)
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
def plot_confusion_matrix(y_true, y_pred, class_names, save_path='confusion_matrix.png'):
|
||||||
|
"""
|
||||||
|
Menampilkan confusion matrix dalam bentuk heatmap dan menyimpannya sebagai file PNG
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
y_true: label aktual
|
||||||
|
y_pred: label prediksi
|
||||||
|
class_names: list nama kelas
|
||||||
|
save_path: path untuk menyimpan file gambar
|
||||||
|
"""
|
||||||
|
# Hitung confusion matrix
|
||||||
|
cm = confusion_matrix(y_true, y_pred)
|
||||||
|
|
||||||
|
# Buat figure
|
||||||
|
plt.figure(figsize=(10, 8))
|
||||||
|
|
||||||
|
# Buat heatmap dengan seaborn
|
||||||
|
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
||||||
|
xticklabels=class_names, yticklabels=class_names,
|
||||||
|
cbar_kws={'label': 'Jumlah Sampel'},
|
||||||
|
square=True,
|
||||||
|
linewidths=0.5,
|
||||||
|
annot_kws={'size': 12, 'weight': 'bold'})
|
||||||
|
|
||||||
|
# Set title dan labels
|
||||||
|
plt.title('Confusion Matrix - Klasifikasi Tingkat Kematangan Tomat',
|
||||||
|
fontsize=16, fontweight='bold', pad=20)
|
||||||
|
plt.xlabel('Kelas Prediksi', fontsize=12, fontweight='bold')
|
||||||
|
plt.ylabel('Kelas Aktual', fontsize=12, fontweight='bold')
|
||||||
|
|
||||||
|
# Rotate labels untuk better readability
|
||||||
|
plt.xticks(rotation=45, ha='right')
|
||||||
|
plt.yticks(rotation=0)
|
||||||
|
|
||||||
|
# Add text summary
|
||||||
|
total_samples = len(y_true)
|
||||||
|
accuracy = np.mean(y_true == y_pred) * 100
|
||||||
|
plt.figtext(0.5, 0.02, f'Total Sampel: {total_samples} | Akurasi: {accuracy:.2f}%',
|
||||||
|
ha='center', fontsize=11, style='italic')
|
||||||
|
|
||||||
|
# Adjust layout
|
||||||
|
plt.tight_layout()
|
||||||
|
|
||||||
|
# Save sebagai PNG dengan high quality
|
||||||
|
plt.savefig(save_path, dpi=300, bbox_inches='tight', facecolor='white')
|
||||||
|
|
||||||
|
# Tampilkan plot
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(f"\nConfusion Matrix telah disimpan sebagai: {save_path}")
|
||||||
|
print(f"Ukuran gambar: 300 DPI")
|
||||||
|
|
||||||
|
return cm
|
||||||
|
|
||||||
|
def load_dataset(dataset_path):
|
||||||
|
"""
|
||||||
|
Membaca seluruh gambar dari folder dataset dan melakukan ekstraksi fitur
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
dataset_path: path ke folder dataset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
features: array fitur dari semua gambar
|
||||||
|
labels: array label dari semua gambar
|
||||||
|
class_names: nama kelas
|
||||||
|
"""
|
||||||
|
features = []
|
||||||
|
labels = []
|
||||||
|
class_names = []
|
||||||
|
|
||||||
|
# Dapatkan semua folder kelas
|
||||||
|
classes = [d for d in os.listdir(dataset_path)
|
||||||
|
if os.path.isdir(os.path.join(dataset_path, d))]
|
||||||
|
classes.sort() # Urutkan untuk konsistensi
|
||||||
|
|
||||||
|
print(f"Ditemukan {len(classes)} kelas: {classes}")
|
||||||
|
|
||||||
|
for class_idx, class_name in enumerate(classes):
|
||||||
|
class_path = os.path.join(dataset_path, class_name)
|
||||||
|
class_names.append(class_name)
|
||||||
|
|
||||||
|
# Dapatkan semua file gambar dalam folder kelas
|
||||||
|
image_files = [f for f in os.listdir(class_path)
|
||||||
|
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
|
||||||
|
|
||||||
|
print(f"Memproses kelas '{class_name}': {len(image_files)} gambar")
|
||||||
|
|
||||||
|
for image_file in image_files:
|
||||||
|
image_path = os.path.join(class_path, image_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Baca gambar
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
print(f"Warning: Tidak dapat membaca gambar {image_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ekstraksi fitur
|
||||||
|
feature = extract_color_histogram(image)
|
||||||
|
features.append(feature)
|
||||||
|
labels.append(class_idx)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error memproses {image_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return np.array(features), np.array(labels), class_names
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Fungsi utama untuk menjalankan proses klasifikasi tingkat kematangan tomat
|
||||||
|
"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("KLASIFIKASI TINGKAT KEMATANGAN TOMAT")
|
||||||
|
print("Menggunakan Random Forest dan Color Histogram HSV")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Path ke dataset
|
||||||
|
dataset_path = "."
|
||||||
|
|
||||||
|
# Cek apakah folder dataset ada
|
||||||
|
if not os.path.exists(dataset_path):
|
||||||
|
print(f"Error: Folder '{dataset_path}' tidak ditemukan!")
|
||||||
|
print("Pastikan folder dataset ada dengan struktur:")
|
||||||
|
print("./")
|
||||||
|
print(" matang/")
|
||||||
|
print(" mentah/")
|
||||||
|
print(" setengah_matang/")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load dataset dan ekstraksi fitur
|
||||||
|
print("\n1. MEMBACA DATASET DAN EKSTRAKSI FITUR")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
features, labels, class_names = load_dataset(dataset_path)
|
||||||
|
|
||||||
|
print(f"\nTotal gambar yang berhasil diproses: {len(features)}")
|
||||||
|
print(f"Dimensi fitur per gambar: {features.shape[1]}")
|
||||||
|
|
||||||
|
# Split dataset menjadi training dan testing (80:20)
|
||||||
|
print("\n2. SPLIT DATASET")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
|
features, labels,
|
||||||
|
test_size=0.2,
|
||||||
|
random_state=42,
|
||||||
|
stratify=labels
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tampilkan jumlah data training dan testing
|
||||||
|
print(f"Jumlah data training: {len(X_train)}")
|
||||||
|
print(f"Jumlah data testing: {len(X_test)}")
|
||||||
|
print(f"Rasio training:testing = {len(X_train)/(len(X_train)+len(X_test)):.1f}:{len(X_test)/(len(X_train)+len(X_test)):.1f}")
|
||||||
|
|
||||||
|
# Tampilkan distribusi kelas
|
||||||
|
print("\nDistribusi kelas pada data training:")
|
||||||
|
for i, class_name in enumerate(class_names):
|
||||||
|
count = np.sum(y_train == i)
|
||||||
|
print(f" {class_name}: {count} gambar")
|
||||||
|
|
||||||
|
print("\nDistribusi kelas pada data testing:")
|
||||||
|
for i, class_name in enumerate(class_names):
|
||||||
|
count = np.sum(y_test == i)
|
||||||
|
print(f" {class_name}: {count} gambar")
|
||||||
|
|
||||||
|
# Training model Random Forest
|
||||||
|
print("\n3. TRAINING MODEL RANDOM FOREST")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Inisialisasi model
|
||||||
|
rf_model = RandomForestClassifier(
|
||||||
|
n_estimators=100,
|
||||||
|
random_state=42,
|
||||||
|
max_depth=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Training model
|
||||||
|
rf_model.fit(X_train, y_train)
|
||||||
|
print("Model Random Forest telah selesai training!")
|
||||||
|
|
||||||
|
# Evaluasi model
|
||||||
|
print("\n4. EVALUASI MODEL")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Prediksi pada data testing
|
||||||
|
y_pred = rf_model.predict(X_test)
|
||||||
|
|
||||||
|
# Tampilkan classification report
|
||||||
|
print("\nClassification Report:")
|
||||||
|
print(classification_report(y_test, y_pred, target_names=class_names))
|
||||||
|
|
||||||
|
# Tampilkan confusion matrix dengan visualisasi yang lebih baik
|
||||||
|
print("\nConfusion Matrix:")
|
||||||
|
cm = plot_confusion_matrix(y_test, y_pred, class_names, 'confusion_matrix_tomat.png')
|
||||||
|
print(cm)
|
||||||
|
|
||||||
|
# Tampilkan akurasi
|
||||||
|
accuracy = np.mean(y_pred == y_test)
|
||||||
|
print(f"\nAkurasi model: {accuracy:.4f} ({accuracy*100:.2f}%)")
|
||||||
|
|
||||||
|
# Feature importance
|
||||||
|
print("\n5. FEATURE IMPORTANCE")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Dapatkan feature importance
|
||||||
|
importances = rf_model.feature_importances_
|
||||||
|
|
||||||
|
# Kelompokkan berdasarkan channel RGB
|
||||||
|
bins_per_channel = len(importances) // 3
|
||||||
|
r_importance = np.sum(importances[:bins_per_channel])
|
||||||
|
g_importance = np.sum(importances[bins_per_channel:2*bins_per_channel])
|
||||||
|
b_importance = np.sum(importances[2*bins_per_channel:])
|
||||||
|
|
||||||
|
print(f"Importance channel R: {r_importance:.4f} ({r_importance*100:.2f}%)")
|
||||||
|
print(f"Importance channel G: {g_importance:.4f} ({g_importance*100:.2f}%)")
|
||||||
|
print(f"Importance channel B: {b_importance:.4f} ({b_importance*100:.2f}%)")
|
||||||
|
|
||||||
|
# Simpan model
|
||||||
|
print("\n6. MENYIMPAN MODEL")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Buat folder models jika belum ada
|
||||||
|
models_folder = "models"
|
||||||
|
if not os.path.exists(models_folder):
|
||||||
|
os.makedirs(models_folder)
|
||||||
|
print(f"Folder '{models_folder}' dibuat")
|
||||||
|
|
||||||
|
# Buat label encoder
|
||||||
|
label_encoder = LabelEncoder()
|
||||||
|
label_encoder.fit(class_names)
|
||||||
|
|
||||||
|
# Simpan model
|
||||||
|
try:
|
||||||
|
model_file = os.path.join(models_folder, "tomat_classifier.pkl")
|
||||||
|
joblib.dump(rf_model, model_file)
|
||||||
|
print(f"Model berhasil disimpan: {model_file}")
|
||||||
|
|
||||||
|
# Simpan label encoder
|
||||||
|
encoder_file = os.path.join(models_folder, "label_encoder.pkl")
|
||||||
|
joblib.dump(label_encoder, encoder_file)
|
||||||
|
print(f"Label encoder berhasil disimpan: {encoder_file}")
|
||||||
|
|
||||||
|
# Simpan metadata
|
||||||
|
metadata = {
|
||||||
|
'model_type': 'RandomForestClassifier',
|
||||||
|
'n_estimators': 100,
|
||||||
|
'max_depth': 10,
|
||||||
|
'n_features': features.shape[1],
|
||||||
|
'classes': class_names,
|
||||||
|
'accuracy': accuracy,
|
||||||
|
'histogram_bins': (8, 8, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_file = os.path.join(models_folder, "metadata.pkl")
|
||||||
|
joblib.dump(metadata, metadata_file)
|
||||||
|
print(f"Metadata berhasil disimpan: {metadata_file}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error menyimpan model: {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PROSES KLASIFIKASI SELESAI!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 799 KiB |
|
After Width: | Height: | Size: 835 KiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 885 KiB |
|
After Width: | Height: | Size: 808 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 863 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 898 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 787 KiB |
|
After Width: | Height: | Size: 866 KiB |
|
After Width: | Height: | Size: 862 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 842 KiB |
|
After Width: | Height: | Size: 764 KiB |
|
After Width: | Height: | Size: 857 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 827 KiB |
|
After Width: | Height: | Size: 785 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 764 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 603 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 584 KiB |
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 922 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 352 KiB |