Spaces:
Paused
Paused
Update the Project
Browse files- app.py +312 -58
- config.py +127 -0
- models/FirenetCNN.h5 +3 -0
- models/FirenetCNN1.h5 +3 -0
- models/firenet_model.h5 +3 -0
- packages.txt +3 -0
- requirements.txt +11 -0
- src/__init__.py +12 -0
- src/__pycache__/__init__.cpython-313.pyc +0 -0
- src/__pycache__/gradcam.cpython-313.pyc +0 -0
- src/__pycache__/inference.cpython-313.pyc +0 -0
- src/__pycache__/model.cpython-313.pyc +0 -0
- src/__pycache__/training.cpython-313.pyc +0 -0
- src/gradcam.py +200 -0
- src/inference.py +539 -0
- src/model.py +224 -0
- src/training.py +165 -0
app.py
CHANGED
|
@@ -1,69 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
-
|
|
|
|
| 3 |
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
history: list[dict[str, str]],
|
| 8 |
-
system_message,
|
| 9 |
-
max_tokens,
|
| 10 |
-
temperature,
|
| 11 |
-
top_p,
|
| 12 |
-
hf_token: gr.OAuthToken,
|
| 13 |
-
):
|
| 14 |
-
"""
|
| 15 |
-
For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
|
| 16 |
-
"""
|
| 17 |
-
client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
messages.extend(history)
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
response = ""
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
response += token
|
| 40 |
-
yield response
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
if
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py - Gradio Web Application for Forest Fire Detection
|
| 3 |
+
Provides a web interface for image classification, video analysis, and model info.
|
| 4 |
+
"""
|
| 5 |
+
# `spaces` must be imported before any CUDA-touching library (tensorflow is
|
| 6 |
+
# imported transitively below via `src.inference`) for Hugging Face ZeroGPU.
|
| 7 |
+
import spaces
|
| 8 |
+
import os
|
| 9 |
+
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
| 10 |
+
|
| 11 |
+
# Fix invalid SSL_CERT_FILE on Windows (points to non-existent path)
|
| 12 |
+
ssl_cert = os.environ.get('SSL_CERT_FILE', '')
|
| 13 |
+
if ssl_cert and not os.path.exists(ssl_cert):
|
| 14 |
+
del os.environ['SSL_CERT_FILE']
|
| 15 |
+
|
| 16 |
+
import tempfile
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import cv2
|
| 21 |
import gradio as gr
|
| 22 |
+
import numpy as np
|
| 23 |
+
from PIL import Image
|
| 24 |
|
| 25 |
+
from src.inference import FireNetInference
|
| 26 |
+
from src.model import FireNetModel
|
| 27 |
|
| 28 |
+
# Default model path (override with MODEL_PATH env var, e.g. as a Space secret)
|
| 29 |
+
DEFAULT_MODEL = os.environ.get('MODEL_PATH', 'models/FirenetCNN1.h5')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
# Class descriptions
|
| 32 |
+
CLASS_INFO = {
|
| 33 |
+
'fire': {
|
| 34 |
+
'color': 'Red',
|
| 35 |
+
'description': 'Active fire detected with high confidence. Immediate attention required.',
|
| 36 |
+
'icon': '🔥'
|
| 37 |
+
},
|
| 38 |
+
'no_fire': {
|
| 39 |
+
'color': 'Green',
|
| 40 |
+
'description': 'No fire detected. Scene appears safe.',
|
| 41 |
+
'icon': '✅'
|
| 42 |
+
},
|
| 43 |
+
'smoke': {
|
| 44 |
+
'color': 'Orange',
|
| 45 |
+
'description': 'Smoke detected. May indicate early-stage fire or controlled burn.',
|
| 46 |
+
'icon': '💨'
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
|
|
|
|
| 50 |
|
| 51 |
+
_engine = None
|
| 52 |
|
|
|
|
| 53 |
|
| 54 |
+
def get_inference_engine() -> FireNetInference:
|
| 55 |
+
"""Get or create the cached inference engine (loaded once per process)."""
|
| 56 |
+
global _engine
|
| 57 |
+
if _engine is None:
|
| 58 |
+
model_path = DEFAULT_MODEL
|
| 59 |
+
if not os.path.exists(model_path):
|
| 60 |
+
# Try alternative paths
|
| 61 |
+
alternatives = [
|
| 62 |
+
'models/FirenetCNN.keras',
|
| 63 |
+
'models/FirenetCNN.h5',
|
| 64 |
+
'models/firenet_model.h5',
|
| 65 |
+
'FirenetCNN1.h5',
|
| 66 |
+
'FirenetCNN.h5',
|
| 67 |
+
]
|
| 68 |
+
for alt in alternatives:
|
| 69 |
+
if os.path.exists(alt):
|
| 70 |
+
model_path = alt
|
| 71 |
+
break
|
| 72 |
+
_engine = FireNetInference(model_path)
|
| 73 |
+
return _engine
|
| 74 |
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
@spaces.GPU(duration=30)
|
| 77 |
+
def predict_image(image, apply_gradcam=True):
|
| 78 |
+
"""Predict on a single image with optional Grad-CAM."""
|
| 79 |
+
if image is None:
|
| 80 |
+
return None, "Please upload an image.", None, None
|
| 81 |
|
| 82 |
+
try:
|
| 83 |
+
engine = get_inference_engine()
|
| 84 |
+
|
| 85 |
+
# Convert PIL to temp file for inference
|
| 86 |
+
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
|
| 87 |
+
if isinstance(image, np.ndarray):
|
| 88 |
+
cv2.imwrite(tmp.name, cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
|
| 89 |
+
else:
|
| 90 |
+
image.save(tmp.name)
|
| 91 |
+
tmp_path = tmp.name
|
| 92 |
+
|
| 93 |
+
result = engine.predict_image(tmp_path, apply_gradcam=apply_gradcam)
|
| 94 |
+
|
| 95 |
+
# Cleanup
|
| 96 |
+
os.unlink(tmp_path)
|
| 97 |
+
|
| 98 |
+
# Prepare outputs
|
| 99 |
+
label = result['label']
|
| 100 |
+
confidence = result['confidence']
|
| 101 |
+
probs = result['probability_array']
|
| 102 |
+
|
| 103 |
+
# Create annotated image
|
| 104 |
+
if result.get('annotated_image') is not None:
|
| 105 |
+
annotated = cv2.cvtColor(result['annotated_image'], cv2.COLOR_BGR2RGB)
|
| 106 |
+
elif result.get('heatmap') is not None and result['has_gradcam']:
|
| 107 |
+
original = cv2.imread(tmp_path) if os.path.exists(tmp_path) else None
|
| 108 |
+
if original is not None:
|
| 109 |
+
rgb_orig = cv2.cvtColor(original, cv2.COLOR_BGR2RGB)
|
| 110 |
+
heatmap = np.array(result['heatmap'])
|
| 111 |
+
annotated = engine.gradcam.overlay_heatmap(rgb_orig, heatmap, alpha=0.5)
|
| 112 |
+
else:
|
| 113 |
+
annotated = np.array(image)
|
| 114 |
+
else:
|
| 115 |
+
annotated = np.array(image)
|
| 116 |
+
|
| 117 |
+
# Create probability chart
|
| 118 |
+
prob_dict = {cls: float(probs[i]) for i, cls in enumerate(FireNetModel.CLASS_LABELS)}
|
| 119 |
+
|
| 120 |
+
# Format result text
|
| 121 |
+
info = CLASS_INFO.get(label, {})
|
| 122 |
+
result_text = f"## {info.get('icon', '')} Prediction: **{label.upper()}**\n"
|
| 123 |
+
result_text += f"**Confidence:** {confidence*100:.2f}%\n\n"
|
| 124 |
+
result_text += f"**Details:** {info.get('description', 'N/A')}\n\n"
|
| 125 |
+
result_text += "### Class Probabilities\n"
|
| 126 |
+
for cls, prob in prob_dict.items():
|
| 127 |
+
bar = '█' * int(prob * 20)
|
| 128 |
+
result_text += f"- **{cls}**: {prob*100:.1f}% {bar}\n"
|
| 129 |
+
|
| 130 |
+
return annotated, result_text, prob_dict, None
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
return None, f"Error: {str(e)}", None, None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@spaces.GPU(duration=120)
|
| 137 |
+
def predict_video(video_path, skip_frames=5, apply_gradcam=True):
|
| 138 |
+
"""Process a video file frame-by-frame."""
|
| 139 |
+
if video_path is None:
|
| 140 |
+
return None, "Please upload a video.", None
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
engine = get_inference_engine()
|
| 144 |
+
|
| 145 |
+
# Create output path
|
| 146 |
+
output_path = tempfile.mktemp(suffix='.mp4')
|
| 147 |
+
|
| 148 |
+
stats = engine.predict_video(
|
| 149 |
+
video_path,
|
| 150 |
+
output_path=output_path,
|
| 151 |
+
skip_frames=skip_frames,
|
| 152 |
+
apply_gradcam=apply_gradcam
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Format stats
|
| 156 |
+
result_text = f"## Video Analysis Complete\n\n"
|
| 157 |
+
result_text += f"**Total Frames:** {stats['total_frames']}\n"
|
| 158 |
+
result_text += f"**Processed Frames:** {stats['processed_frames']}\n"
|
| 159 |
+
result_text += f"**Processing Time:** {stats['processing_time_seconds']:.2f}s\n"
|
| 160 |
+
result_text += f"**FPS:** {stats['processed_frames']/max(stats['processing_time_seconds'],0.001):.1f}\n\n"
|
| 161 |
+
|
| 162 |
+
# Class distribution
|
| 163 |
+
labels = [f['label'] for f in stats['frame_by_frame'] if f['processed']]
|
| 164 |
+
if labels:
|
| 165 |
+
from collections import Counter
|
| 166 |
+
counts = Counter(labels)
|
| 167 |
+
result_text += "### Detection Summary\n"
|
| 168 |
+
for cls, count in counts.most_common():
|
| 169 |
+
pct = count / len(labels) * 100
|
| 170 |
+
result_text += f"- **{cls}**: {count} frames ({pct:.1f}%)\n"
|
| 171 |
+
|
| 172 |
+
return output_path, result_text, stats
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
return None, f"Error: {str(e)}", None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def get_model_info():
|
| 179 |
+
"""Get model information and statistics."""
|
| 180 |
+
try:
|
| 181 |
+
engine = get_inference_engine()
|
| 182 |
+
config = FireNetModel.get_model_config()
|
| 183 |
+
|
| 184 |
+
info_text = f"## Model Information\n\n"
|
| 185 |
+
info_text += f"**Architecture:** {config['architecture']}\n"
|
| 186 |
+
info_text += f"**Input Shape:** {config['input_shape']}\n"
|
| 187 |
+
info_text += f"**Number of Classes:** {config['num_classes']}\n"
|
| 188 |
+
info_text += f"**Class Labels:** {', '.join(config['class_labels'])}\n"
|
| 189 |
+
info_text += f"**Learning Rate:** {config['learning_rate']}\n"
|
| 190 |
+
info_text += f"**Image Size:** {config['image_size']}\n"
|
| 191 |
+
info_text += f"**Batch Size:** {config['batch_size']}\n"
|
| 192 |
+
info_text += f"**Grad-CAM Layer:** {config['last_conv_layer']}\n\n"
|
| 193 |
+
|
| 194 |
+
info_text += "### Model Files\n"
|
| 195 |
+
models_dir = Path('models')
|
| 196 |
+
if models_dir.exists():
|
| 197 |
+
for f in models_dir.glob('*'):
|
| 198 |
+
if f.suffix in ('.h5', '.keras'):
|
| 199 |
+
size_mb = f.stat().st_size / (1024 * 1024)
|
| 200 |
+
info_text += f"- **{f.name}**: {size_mb:.1f} MB\n"
|
| 201 |
+
|
| 202 |
+
info_text += "\n### Class Reference\n"
|
| 203 |
+
for cls, details in CLASS_INFO.items():
|
| 204 |
+
info_text += f"- {details['icon']} **{cls}**: {details['description']}\n"
|
| 205 |
+
|
| 206 |
+
return info_text
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
return f"Error loading model info: {str(e)}"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# Build Gradio interface
|
| 213 |
+
with gr.Blocks(title="Forest Fire Detection - FirenetCNN", theme=gr.themes.Soft()) as demo:
|
| 214 |
+
gr.Markdown(
|
| 215 |
+
"""
|
| 216 |
+
# 🔥 Forest Fire Detection using FirenetCNN and XAI Techniques
|
| 217 |
+
|
| 218 |
+
Detect and classify forest fires from images and videos using deep learning with explainable AI (Grad-CAM).
|
| 219 |
+
|
| 220 |
+
**Classes:** `fire` | `no_fire` | `smoke`
|
| 221 |
+
"""
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
with gr.Tabs():
|
| 225 |
+
# Tab 1: Image Classification
|
| 226 |
+
with gr.Tab("📷 Image Classification"):
|
| 227 |
+
with gr.Row():
|
| 228 |
+
with gr.Column(scale=1):
|
| 229 |
+
image_input = gr.Image(type="pil", label="Upload Image")
|
| 230 |
+
gradcam_check = gr.Checkbox(label="Apply Grad-CAM", value=True)
|
| 231 |
+
predict_btn = gr.Button("Predict", variant="primary")
|
| 232 |
+
|
| 233 |
+
with gr.Column(scale=1):
|
| 234 |
+
image_output = gr.Image(label="Annotated Result")
|
| 235 |
+
result_text = gr.Markdown(label="Prediction")
|
| 236 |
+
prob_chart = gr.Label(label="Probabilities")
|
| 237 |
+
|
| 238 |
+
predict_btn.click(
|
| 239 |
+
fn=predict_image,
|
| 240 |
+
inputs=[image_input, gradcam_check],
|
| 241 |
+
outputs=[image_output, result_text, prob_chart, gr.State()]
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
# Tab 2: Video Analysis
|
| 245 |
+
with gr.Tab("🎥 Video Analysis"):
|
| 246 |
+
with gr.Row():
|
| 247 |
+
with gr.Column(scale=1):
|
| 248 |
+
video_input = gr.Video(label="Upload Video")
|
| 249 |
+
skip_frames = gr.Slider(
|
| 250 |
+
minimum=1, maximum=30, value=5, step=1,
|
| 251 |
+
label="Process every Nth frame"
|
| 252 |
+
)
|
| 253 |
+
video_gradcam = gr.Checkbox(label="Apply Grad-CAM", value=True)
|
| 254 |
+
video_btn = gr.Button("Analyze Video", variant="primary")
|
| 255 |
+
|
| 256 |
+
with gr.Column(scale=1):
|
| 257 |
+
video_output = gr.Video(label="Processed Video")
|
| 258 |
+
video_stats = gr.Markdown(label="Statistics")
|
| 259 |
+
|
| 260 |
+
video_btn.click(
|
| 261 |
+
fn=predict_video,
|
| 262 |
+
inputs=[video_input, skip_frames, video_gradcam],
|
| 263 |
+
outputs=[video_output, video_stats, gr.State()]
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Tab 3: Webcam Inference
|
| 267 |
+
with gr.Tab("📹 Webcam Inference"):
|
| 268 |
+
gr.Markdown(
|
| 269 |
+
"""
|
| 270 |
+
### Live Webcam Detection
|
| 271 |
+
|
| 272 |
+
Click **Start Camera** to begin real-time fire/smoke detection.
|
| 273 |
+
|
| 274 |
+
**Note:** Webcam inference runs locally in your browser.
|
| 275 |
+
"""
|
| 276 |
+
)
|
| 277 |
+
webcam_input = gr.Image(label="Webcam Feed")
|
| 278 |
+
webcam_output = gr.Image(label="Detection Result")
|
| 279 |
+
|
| 280 |
+
# Webcam processing would need real-time streaming
|
| 281 |
+
# For now, provide a static image upload alternative
|
| 282 |
+
gr.Markdown("*For live webcam detection, use the Python API directly:*")
|
| 283 |
+
gr.Markdown(
|
| 284 |
+
"```python\n"
|
| 285 |
+
"from src.inference import FireNetInference\n"
|
| 286 |
+
"engine = FireNetInference('models/FirenetCNN1.h5')\n"
|
| 287 |
+
"engine.predict_webcam()\n"
|
| 288 |
+
"```"
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# Tab 4: Model Information
|
| 292 |
+
with gr.Tab("📊 Model Information"):
|
| 293 |
+
model_info = gr.Markdown(value=get_model_info)
|
| 294 |
+
|
| 295 |
+
gr.Markdown(
|
| 296 |
+
"""
|
| 297 |
+
### Evaluation Metrics (Test Set)
|
| 298 |
+
|
| 299 |
+
| Class | Precision | Recall | F1-Score | Support |
|
| 300 |
+
|-------|-----------|--------|----------|---------|
|
| 301 |
+
| fire | 0.92 | 0.81 | 0.86 | 121 |
|
| 302 |
+
| no_fire | 0.76 | 0.98 | 0.86 | 146 |
|
| 303 |
+
| smoke | 0.84 | 0.67 | 0.75 | 138 |
|
| 304 |
+
| **accuracy** | | | **0.82** | **405** |
|
| 305 |
+
| macro avg | 0.84 | 0.82 | 0.82 | 405 |
|
| 306 |
+
| weighted avg | 0.83 | 0.82 | 0.82 | 405 |
|
| 307 |
+
"""
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
gr.Markdown(
|
| 311 |
+
"""
|
| 312 |
+
---
|
| 313 |
+
*Built with FirenetCNN (MobileNetV2) + Grad-CAM | [GitHub](https://github.com/OpelSpeedster/Forest-Fire-Detection-Using-FirenetCNN-and-XAI-Techniques)*
|
| 314 |
+
"""
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if __name__ == '__main__':
|
| 319 |
+
demo.queue().launch(
|
| 320 |
+
server_name="0.0.0.0",
|
| 321 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 322 |
+
share=False,
|
| 323 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Development configuration and utilities for Forest Fire Detection project
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, Any
|
| 7 |
+
class Config:
|
| 8 |
+
"""Project configuration management"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.project_root = Path.cwd()
|
| 12 |
+
self.data_dir = self.project_root / "data"
|
| 13 |
+
self.models_dir = self.project_root / "models"
|
| 14 |
+
self.assets_dir = self.project_root / "assets"
|
| 15 |
+
self.notebooks_dir = self.project_root / "notebooks"
|
| 16 |
+
self.src_dir = self.project_root / "src"
|
| 17 |
+
|
| 18 |
+
# Load or create configuration
|
| 19 |
+
self.config_path = self.project_root / "config.json"
|
| 20 |
+
self.load_config()
|
| 21 |
+
|
| 22 |
+
def load_config(self) -> Dict[str, Any]:
|
| 23 |
+
"""Load configuration from file or create default"""
|
| 24 |
+
if self.config_path.exists():
|
| 25 |
+
with open(self.config_path, 'r') as f:
|
| 26 |
+
self.config = json.load(f)
|
| 27 |
+
else:
|
| 28 |
+
self.config = self._get_default_config()
|
| 29 |
+
self.save_config()
|
| 30 |
+
|
| 31 |
+
return self.config
|
| 32 |
+
|
| 33 |
+
def _get_default_config(self) -> Dict[str, Any]:
|
| 34 |
+
"""Get default project configuration"""
|
| 35 |
+
return {
|
| 36 |
+
"project_name": "Forest Fire Detection using FirenetCNN and XAI Techniques",
|
| 37 |
+
"version": "1.0.0",
|
| 38 |
+
"description": "Detect and classify forest fires with explainable AI",
|
| 39 |
+
"model": {
|
| 40 |
+
"input_size": [224, 224],
|
| 41 |
+
"num_classes": 3,
|
| 42 |
+
"class_labels": ["fire", "no_fire", "smoke"],
|
| 43 |
+
"reverse_class_map": {"fire": 0, "no_fire": 1, "smoke": 2}
|
| 44 |
+
},
|
| 45 |
+
"gradcam": {
|
| 46 |
+
"target_layer": "out_relu",
|
| 47 |
+
"alpha": 0.5,
|
| 48 |
+
"colormap": "cv2.COLORMAP_JET"
|
| 49 |
+
},
|
| 50 |
+
"app": {
|
| 51 |
+
"title": "Forest Fire Detection - FirenetCNN",
|
| 52 |
+
"port": 7860,
|
| 53 |
+
"share": True
|
| 54 |
+
},
|
| 55 |
+
"paths": {
|
| 56 |
+
"data_dir": "data",
|
| 57 |
+
"models_dir": "models",
|
| 58 |
+
"assets_dir": "assets",
|
| 59 |
+
"notebooks_dir": "notebooks",
|
| 60 |
+
"src_dir": "src"
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
def save_config(self) -> None:
|
| 65 |
+
"""Save configuration to file"""
|
| 66 |
+
with open(self.config_path, 'w') as f:
|
| 67 |
+
json.dump(self.config, f, indent=2)
|
| 68 |
+
|
| 69 |
+
def get_model_paths(self) -> Dict[str, str]:
|
| 70 |
+
"""Get all model file paths"""
|
| 71 |
+
return {
|
| 72 |
+
"modern": str(self.models_dir / "FirenetCNN.keras"),
|
| 73 |
+
"legacy": str(self.models_dir / "FirenetCNN1.h5"),
|
| 74 |
+
"alternative": str(self.models_dir / "firenet_model.h5")
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def setup_directories(self) -> None:
|
| 78 |
+
"""Create necessary directories"""
|
| 79 |
+
directories = [
|
| 80 |
+
self.models_dir,
|
| 81 |
+
self.assets_dir,
|
| 82 |
+
self.notebooks_dir,
|
| 83 |
+
self.src_dir,
|
| 84 |
+
self.data_dir
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
for directory in directories:
|
| 88 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
|
| 90 |
+
def validate_environment(self) -> bool:
|
| 91 |
+
"""Validate that required files and directories exist"""
|
| 92 |
+
required_items = [
|
| 93 |
+
self.notebooks_dir / "Fire_PredCopy.ipynb"
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
for item in required_items:
|
| 97 |
+
if not item.exists():
|
| 98 |
+
print(f"⚠️ Warning: Required item not found: {item}")
|
| 99 |
+
|
| 100 |
+
return all(item.exists() for item in required_items)
|
| 101 |
+
|
| 102 |
+
def print_summary(self) -> None:
|
| 103 |
+
"""Print project structure summary"""
|
| 104 |
+
print("=" * 60)
|
| 105 |
+
print(f"🚀 {self.config['project_name']}")
|
| 106 |
+
print("=" * 60)
|
| 107 |
+
print(f"📝 Version: {self.config['version']}")
|
| 108 |
+
print(f"📝 Description: {self.config['description']}")
|
| 109 |
+
print()
|
| 110 |
+
print("📂 Project Structure:")
|
| 111 |
+
print(f" • Models: {self.models_dir}/")
|
| 112 |
+
print(f" • Assets: {self.assets_dir}/")
|
| 113 |
+
print(f" • Notebooks: {self.notebooks_dir}/")
|
| 114 |
+
print(f" • Source: {self.src_dir}/")
|
| 115 |
+
print(f" • Data: {self.data_dir}/")
|
| 116 |
+
print()
|
| 117 |
+
print("🔧 Key Features:")
|
| 118 |
+
print(" • FirenetCNN (MobileNetV2 based)")
|
| 119 |
+
print(" • Grad-CAM Explainable AI")
|
| 120 |
+
print(" • Gradio Web Interface")
|
| 121 |
+
print(" • Image & Video Analysis")
|
| 122 |
+
print(" • Model Conversion Tools")
|
| 123 |
+
print(" • Full Documentation")
|
| 124 |
+
print("=" * 60)
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
config = Config()
|
| 127 |
+
config.print_summary()
|
models/FirenetCNN.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c580135a798d9855914e77031e3028112c3b993d94c058536cfe419ff5353554
|
| 3 |
+
size 25352336
|
models/FirenetCNN1.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d3bc11fdafb36cf0e43ea1af44015097a62a67118b43d27f0badd2b5867c8525
|
| 3 |
+
size 25352336
|
models/firenet_model.h5
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6f39472ed93085dc6aa77b3c48d1cc741f40ab693b68eb003981b585b7277b7d
|
| 3 |
+
size 2113688
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
| 2 |
+
libgl1
|
| 3 |
+
libglib2.0-0
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tensorflow[and-cuda]>=2.15.0,<2.20.0
|
| 2 |
+
opencv-python-headless>=4.8.0
|
| 3 |
+
numpy>=1.24.0
|
| 4 |
+
Pillow>=10.0.0
|
| 5 |
+
matplotlib>=3.7.0
|
| 6 |
+
seaborn>=0.12.0
|
| 7 |
+
scikit-learn>=1.3.0
|
| 8 |
+
gradio>=4.44.0
|
| 9 |
+
spaces>=0.30.0
|
| 10 |
+
requests>=2.31.0
|
| 11 |
+
python-dateutil>=2.8.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Forest Fire Detection using FirenetCNN and XAI Techniques
|
| 3 |
+
src/__init__.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "1.0.0"
|
| 7 |
+
|
| 8 |
+
from .model import FireNetModel
|
| 9 |
+
from .gradcam import GradCAM
|
| 10 |
+
from .inference import FireNetInference
|
| 11 |
+
|
| 12 |
+
__all__ = ["FireNetModel", "GradCAM", "FireNetInference"]
|
src/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (497 Bytes). View file
|
|
|
src/__pycache__/gradcam.cpython-313.pyc
ADDED
|
Binary file (8.23 kB). View file
|
|
|
src/__pycache__/inference.cpython-313.pyc
ADDED
|
Binary file (21 kB). View file
|
|
|
src/__pycache__/model.cpython-313.pyc
ADDED
|
Binary file (8.67 kB). View file
|
|
|
src/__pycache__/training.cpython-313.pyc
ADDED
|
Binary file (6.38 kB). View file
|
|
|
src/gradcam.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/gradcam.py
|
| 3 |
+
Grad-CAM implementation for visualizing model decisions
|
| 4 |
+
"""
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
import tensorflow as tf
|
| 8 |
+
from typing import Optional, Tuple, Union
|
| 9 |
+
class GradCAM:
|
| 10 |
+
"""
|
| 11 |
+
Gradient-weighted Class Activation Mapping (Grad-CAM)
|
| 12 |
+
|
| 13 |
+
Visualizes which regions of an image the model focuses on for its predictions.
|
| 14 |
+
Based on the original Grad-CAM paper: "Grad-CAM: Visual Explanations from Deep Neural Networks via Gradient-based Localization"
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, model, last_conv_layer_name: str = 'out_relu'):
|
| 18 |
+
"""
|
| 19 |
+
Initialize Grad-CAM with a Keras model.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
model: Keras model
|
| 23 |
+
last_conv_layer_name: Name of the last convolutional layer to use
|
| 24 |
+
"""
|
| 25 |
+
self.model = model
|
| 26 |
+
self.last_conv_layer_name = last_conv_layer_name
|
| 27 |
+
|
| 28 |
+
# Create the gradient model once
|
| 29 |
+
self.grad_model = self._create_gradient_model()
|
| 30 |
+
|
| 31 |
+
def _create_gradient_model(self):
|
| 32 |
+
"""
|
| 33 |
+
Create a model that connects the last conv layer and model output.
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Keras Model that outputs both the last conv layer and predictions
|
| 37 |
+
"""
|
| 38 |
+
return tf.keras.Model(
|
| 39 |
+
[self.model.inputs],
|
| 40 |
+
[self.model.get_layer(self.last_conv_layer_name).output, self.model.output]
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
def generate_heatmap(self, image_array: np.ndarray, class_index: int) -> np.ndarray:
|
| 44 |
+
"""
|
| 45 |
+
Generate a Grad-CAM heatmap for a given image and class.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
image_array: Preprocessed image array (with batch dimension)
|
| 49 |
+
class_index: Index of the class to generate heatmap for
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Generated heatmap (numpy array, shape: H x W)
|
| 53 |
+
"""
|
| 54 |
+
with tf.GradientTape() as tape:
|
| 55 |
+
last_conv_layer_output, preds = self.grad_model(image_array)
|
| 56 |
+
class_channel = preds[:, class_index]
|
| 57 |
+
|
| 58 |
+
grads = tape.gradient(class_channel, last_conv_layer_output)
|
| 59 |
+
|
| 60 |
+
# Pool gradients across spatial dimensions
|
| 61 |
+
pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2))
|
| 62 |
+
|
| 63 |
+
# Get the output of the last conv layer
|
| 64 |
+
last_conv_layer_output = last_conv_layer_output[0]
|
| 65 |
+
|
| 66 |
+
# Compute the weighted combination of feature maps
|
| 67 |
+
heatmap = last_conv_layer_output @ pooled_grads[..., tf.newaxis]
|
| 68 |
+
heatmap = tf.squeeze(heatmap)
|
| 69 |
+
|
| 70 |
+
# Apply ReLU to get positive weights
|
| 71 |
+
heatmap = tf.maximum(heatmap, 0)
|
| 72 |
+
|
| 73 |
+
# Normalize heatmap to [0, 1]
|
| 74 |
+
max_val = tf.math.reduce_max(heatmap)
|
| 75 |
+
if max_val > 0:
|
| 76 |
+
heatmap = heatmap / max_val
|
| 77 |
+
|
| 78 |
+
return heatmap.numpy()
|
| 79 |
+
|
| 80 |
+
def overlay_heatmap(self, original_image: np.ndarray, heatmap: np.ndarray,
|
| 81 |
+
alpha: float = 0.5) -> np.ndarray:
|
| 82 |
+
"""
|
| 83 |
+
Overlay heatmap on original image using JET colormap.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
original_image: Original image (numpy array, RGB)
|
| 87 |
+
heatmap: Grad-CAM heatmap (normalized to [0, 1])
|
| 88 |
+
alpha: Weight for heatmap in overlay (0-1)
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
Image with heatmap overlay (numpy array, RGB)
|
| 92 |
+
"""
|
| 93 |
+
# Resize heatmap to match original image dimensions
|
| 94 |
+
heatmap = cv2.resize(heatmap, (original_image.shape[1], original_image.shape[0]))
|
| 95 |
+
|
| 96 |
+
# Convert heatmap to 8-bit format and apply JET colormap
|
| 97 |
+
heatmap = np.uint8(255 * heatmap)
|
| 98 |
+
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
|
| 99 |
+
|
| 100 |
+
# Convert original image to float for blending
|
| 101 |
+
original_float = original_image.astype(np.float32)
|
| 102 |
+
heatmap_float = heatmap.astype(np.float32)
|
| 103 |
+
|
| 104 |
+
# Blend original image with heatmap
|
| 105 |
+
superimposed_img = heatmap_float * alpha + original_float
|
| 106 |
+
superimposed_img = np.clip(superimposed_img, 0, 255).astype(np.uint8)
|
| 107 |
+
|
| 108 |
+
return superimposed_img
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
def preprocess_frame(frame: np.ndarray, target_size: Tuple[int, int] = (224, 224)):
|
| 112 |
+
"""
|
| 113 |
+
Preprocess a video frame for model inference.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
frame: Input frame (numpy array, BGR format from OpenCV)
|
| 117 |
+
target_size: Target size for model input
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Preprocessed frame (numpy array)
|
| 121 |
+
"""
|
| 122 |
+
# Resize to model input size
|
| 123 |
+
resized = cv2.resize(frame, target_size)
|
| 124 |
+
# Normalize to [0, 1]
|
| 125 |
+
normalized = resized / 255.0
|
| 126 |
+
# Add batch dimension
|
| 127 |
+
batched = np.expand_dims(normalized, axis=0)
|
| 128 |
+
|
| 129 |
+
return batched
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
def get_confidence_and_prediction(predictions: np.ndarray,
|
| 133 |
+
class_labels: list) -> Tuple[str, float, np.ndarray]:
|
| 134 |
+
"""
|
| 135 |
+
Get prediction label, confidence, and full probability array.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
predictions: Model output probabilities (array of shape [1, 3])
|
| 139 |
+
class_labels: List of class labels
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Tuple of (predicted_label, confidence, probability_array)
|
| 143 |
+
"""
|
| 144 |
+
prob_array = predictions[0]
|
| 145 |
+
max_idx = np.argmax(prob_array)
|
| 146 |
+
confidence = prob_array[max_idx]
|
| 147 |
+
label = class_labels[max_idx]
|
| 148 |
+
|
| 149 |
+
return label, confidence, prob_array
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def get_text_overlay_params(label: str, color_map: dict):
|
| 153 |
+
"""
|
| 154 |
+
Get parameters for text overlay on frames.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
label: Predicted class label
|
| 158 |
+
color_map: Dictionary mapping labels to colors
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Tuple of (text_color, background_color)
|
| 162 |
+
"""
|
| 163 |
+
text_color = color_map.get(label, (255, 255, 255)) # White default
|
| 164 |
+
background_color = (0, 0, 0) # Black background
|
| 165 |
+
return text_color, background_color
|
| 166 |
+
|
| 167 |
+
@staticmethod
|
| 168 |
+
def create_text_overlay(frame: np.ndarray, text: str,
|
| 169 |
+
text_color: Tuple[int, int, int],
|
| 170 |
+
bg_color: Tuple[int, int, int] = (0, 0, 0)):
|
| 171 |
+
"""
|
| 172 |
+
Create a text overlay with background on the frame.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
frame: Input frame
|
| 176 |
+
text: Text to display
|
| 177 |
+
text_color: Color of the text
|
| 178 |
+
bg_color: Background color
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
Frame with text overlay
|
| 182 |
+
"""
|
| 183 |
+
# Get text size
|
| 184 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 185 |
+
font_scale = 0.7
|
| 186 |
+
thickness = 2
|
| 187 |
+
(text_width, text_height), baseline = cv2.getTextSize(text, font, font_scale, thickness)
|
| 188 |
+
|
| 189 |
+
# Define text position with padding
|
| 190 |
+
text_x, text_y = 10, 40
|
| 191 |
+
rect_x1, rect_y1 = text_x - 5, text_y - text_height - baseline - 5
|
| 192 |
+
rect_x2, rect_y2 = text_x + text_width + 5, text_y + baseline + 5
|
| 193 |
+
|
| 194 |
+
# Draw background rectangle
|
| 195 |
+
cv2.rectangle(frame, (rect_x1, rect_y1), (rect_x2, rect_y2), bg_color, -1)
|
| 196 |
+
|
| 197 |
+
# Draw text
|
| 198 |
+
cv2.putText(frame, text, (text_x, text_y), font, font_scale, text_color, thickness)
|
| 199 |
+
|
| 200 |
+
return frame
|
src/inference.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/inference.py
|
| 3 |
+
Unified inference interface for FirenetCNN
|
| 4 |
+
Supports single-image, video, and webcam inference with Grad-CAM visualization
|
| 5 |
+
"""
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
import tensorflow as tf
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional, Tuple, Dict, Any
|
| 11 |
+
from PIL import Image
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
from .model import FireNetModel
|
| 15 |
+
from .gradcam import GradCAM
|
| 16 |
+
class FireNetInference:
|
| 17 |
+
"""
|
| 18 |
+
Unified inference engine for FirenetCNN
|
| 19 |
+
|
| 20 |
+
Provides interface for:
|
| 21 |
+
- Single image inference with optional Grad-CAM
|
| 22 |
+
- Video inference with frame-by-frame processing
|
| 23 |
+
- Real-time webcam inference
|
| 24 |
+
- Model evaluation and reporting
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, model_path: str = 'models/FirenetCNN.keras'):
|
| 28 |
+
"""
|
| 29 |
+
Initialize the inference engine.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
model_path: Path to the trained model file
|
| 33 |
+
"""
|
| 34 |
+
self.model_path = model_path
|
| 35 |
+
self.class_labels = FireNetModel.CLASS_LABELS
|
| 36 |
+
self.reverse_class_map = FireNetModel.REVERSE_CLASS_MAP
|
| 37 |
+
self.color_map = FireNetModel.TEXT_COLOR
|
| 38 |
+
|
| 39 |
+
# Load model and Grad-CAM components
|
| 40 |
+
self.model_wrapper = FireNetModel(model_path)
|
| 41 |
+
self.model = self.model_wrapper.load_pretrained_model()
|
| 42 |
+
self.gradcam = GradCAM(self.model, self.model_wrapper.last_conv_layer_name)
|
| 43 |
+
|
| 44 |
+
# Create gradient model for Grad-CAM
|
| 45 |
+
self.grad_model = tf.keras.Model(
|
| 46 |
+
[self.model.inputs],
|
| 47 |
+
[self.model.get_layer(self.model_wrapper.last_conv_layer_name).output, self.model.output]
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def predict_image(self, image_path: str,
|
| 51 |
+
apply_gradcam: bool = True,
|
| 52 |
+
output_path: Optional[str] = None) -> Dict[str, Any]:
|
| 53 |
+
"""
|
| 54 |
+
Perform inference on a single image.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
image_path: Path to input image
|
| 58 |
+
apply_gradcam: Whether to generate Grad-CAM heatmap
|
| 59 |
+
output_path: Optional path to save annotated image
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Dictionary with prediction results and annotations
|
| 63 |
+
"""
|
| 64 |
+
# Load image
|
| 65 |
+
image = cv2.imread(str(image_path))
|
| 66 |
+
if image is None:
|
| 67 |
+
raise ValueError(f"Could not load image: {image_path}")
|
| 68 |
+
|
| 69 |
+
# Store original for potential output
|
| 70 |
+
original_image = image.copy()
|
| 71 |
+
height, width = image.shape[:2]
|
| 72 |
+
|
| 73 |
+
# Preprocess for model
|
| 74 |
+
input_frame = GradCAM.preprocess_frame(image)
|
| 75 |
+
|
| 76 |
+
# Get predictions
|
| 77 |
+
predictions = self.model.predict(input_frame, verbose=0)
|
| 78 |
+
label, confidence, prob_array = GradCAM.get_confidence_and_prediction(
|
| 79 |
+
predictions, self.class_labels
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Generate Grad-CAM if requested and prediction is fire or smoke
|
| 83 |
+
heatmap = None
|
| 84 |
+
superimposed_img = None
|
| 85 |
+
|
| 86 |
+
if apply_gradcam and label in ['fire', 'smoke']:
|
| 87 |
+
# Convert BGR to RGB for Grad-CAM processing
|
| 88 |
+
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 89 |
+
|
| 90 |
+
# Prepare input for Grad-CAM
|
| 91 |
+
img_array = tf.expand_dims(tf.convert_to_tensor(rgb_image / 255.0), 0)
|
| 92 |
+
class_index = self.reverse_class_map[label]
|
| 93 |
+
|
| 94 |
+
# Generate heatmap
|
| 95 |
+
heatmap = self.gradcam.generate_heatmap(img_array, class_index)
|
| 96 |
+
|
| 97 |
+
# Overlay heatmap
|
| 98 |
+
superimposed_img = self.gradcam.overlay_heatmap(
|
| 99 |
+
original_image, heatmap, alpha=0.5
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Create text overlay parameters
|
| 103 |
+
text_color, bg_color = GradCAM.get_text_overlay_params(label, self.color_map)
|
| 104 |
+
result_text = f"Class: {label} ({confidence*100:.2f}%)"
|
| 105 |
+
|
| 106 |
+
# Add text overlay to image
|
| 107 |
+
annotated_image = GradCAM.create_text_overlay(
|
| 108 |
+
superimposed_img if superimposed_img is not None else original_image,
|
| 109 |
+
result_text, text_color, bg_color
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Save annotated image if output path provided
|
| 113 |
+
if output_path:
|
| 114 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
cv2.imwrite(str(output_path), annotated_image)
|
| 116 |
+
|
| 117 |
+
# Return results as dictionary
|
| 118 |
+
result = {
|
| 119 |
+
'image_path': image_path,
|
| 120 |
+
'label': label,
|
| 121 |
+
'confidence': confidence,
|
| 122 |
+
'probability_array': prob_array.tolist(),
|
| 123 |
+
'class_labels': self.class_labels,
|
| 124 |
+
'has_gradcam': heatmap is not None,
|
| 125 |
+
'heatmap': heatmap.tolist() if heatmap is not None else None,
|
| 126 |
+
'annotated_image': annotated_image if output_path is None else None,
|
| 127 |
+
'processing_time': 0 # Will be measured externally if needed
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return result
|
| 131 |
+
|
| 132 |
+
def predict_video(self, video_path: str,
|
| 133 |
+
output_path: Optional[str] = None,
|
| 134 |
+
skip_frames: int = 5,
|
| 135 |
+
apply_gradcam: bool = True) -> Dict[str, Any]:
|
| 136 |
+
"""
|
| 137 |
+
Process a video file frame-by-frame.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
video_path: Path to input video file
|
| 141 |
+
output_path: Optional path to save processed video
|
| 142 |
+
skip_frames: Process every Nth frame (for performance)
|
| 143 |
+
apply_gradcam: Whether to generate Grad-CAM for fire/smoke frames
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Dictionary with video processing statistics
|
| 147 |
+
"""
|
| 148 |
+
cap = cv2.VideoCapture(video_path)
|
| 149 |
+
if not cap.isOpened():
|
| 150 |
+
raise ValueError(f"Could not open video file: {video_path}")
|
| 151 |
+
|
| 152 |
+
# Get video properties
|
| 153 |
+
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 154 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 155 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 156 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 157 |
+
|
| 158 |
+
# Setup video writer if output path provided
|
| 159 |
+
video_writer = None
|
| 160 |
+
if output_path:
|
| 161 |
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
| 162 |
+
video_writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 163 |
+
|
| 164 |
+
# Statistics
|
| 165 |
+
stats = {
|
| 166 |
+
'total_frames': frame_count,
|
| 167 |
+
'processed_frames': 0,
|
| 168 |
+
'predictions': [],
|
| 169 |
+
'frame_by_frame': [],
|
| 170 |
+
'final_frame': None,
|
| 171 |
+
'processing_time_seconds': 0
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# State tracking across frames
|
| 175 |
+
last_label = "Initializing..."
|
| 176 |
+
last_confidence = 0
|
| 177 |
+
last_heatmap = None
|
| 178 |
+
last_color = (255, 255, 255) # White
|
| 179 |
+
|
| 180 |
+
start_time = time.time()
|
| 181 |
+
frame_number = 0
|
| 182 |
+
|
| 183 |
+
while cap.isOpened() and frame_number < frame_count:
|
| 184 |
+
ret, frame = cap.read()
|
| 185 |
+
if not ret:
|
| 186 |
+
break
|
| 187 |
+
|
| 188 |
+
# Process only every Nth frame for performance
|
| 189 |
+
if frame_number % skip_frames == 0 or frame_number == 0:
|
| 190 |
+
# Preprocess frame
|
| 191 |
+
input_frame = GradCAM.preprocess_frame(frame)
|
| 192 |
+
|
| 193 |
+
# Get predictions
|
| 194 |
+
predictions = self.model.predict(input_frame, verbose=0)
|
| 195 |
+
label, confidence, prob_array = GradCAM.get_confidence_and_prediction(
|
| 196 |
+
predictions, self.class_labels
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Update state
|
| 200 |
+
last_label = label
|
| 201 |
+
last_confidence = confidence
|
| 202 |
+
|
| 203 |
+
# Generate Grad-CAM if requested and prediction is fire or smoke
|
| 204 |
+
if apply_gradcam and label in ['fire', 'smoke']:
|
| 205 |
+
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 206 |
+
img_array = tf.expand_dims(tf.convert_to_tensor(rgb_image / 255.0), 0)
|
| 207 |
+
class_index = self.reverse_class_map[label]
|
| 208 |
+
|
| 209 |
+
last_heatmap = self.gradcam.generate_heatmap(img_array, class_index)
|
| 210 |
+
last_color = self.color_map.get(label, (255, 255, 255))
|
| 211 |
+
else:
|
| 212 |
+
last_heatmap = None
|
| 213 |
+
last_color = self.color_map.get(label, (255, 255, 255))
|
| 214 |
+
|
| 215 |
+
# Update statistics
|
| 216 |
+
stats['processed_frames'] += 1
|
| 217 |
+
|
| 218 |
+
# Apply Grad-CAM if available
|
| 219 |
+
display_frame = frame.copy()
|
| 220 |
+
if last_heatmap is not None:
|
| 221 |
+
display_frame = self.gradcam.overlay_heatmap(frame, last_heatmap, alpha=0.5)
|
| 222 |
+
|
| 223 |
+
# Create text overlay
|
| 224 |
+
text_color, bg_color = GradCAM.get_text_overlay_params(last_label, self.color_map)
|
| 225 |
+
result_text = f"Class: {last_label} ({last_confidence*100:.2f}%)"
|
| 226 |
+
display_frame = GradCAM.create_text_overlay(display_frame, result_text, text_color, bg_color)
|
| 227 |
+
|
| 228 |
+
# Save frame if video writer provided
|
| 229 |
+
if video_writer:
|
| 230 |
+
video_writer.write(display_frame)
|
| 231 |
+
|
| 232 |
+
# Store frame info
|
| 233 |
+
frame_info = {
|
| 234 |
+
'frame_number': frame_number,
|
| 235 |
+
'label': last_label,
|
| 236 |
+
'confidence': last_confidence,
|
| 237 |
+
'processed': (frame_number % skip_frames == 0 or frame_number == 0),
|
| 238 |
+
'has_gradcam': last_heatmap is not None,
|
| 239 |
+
'color_bgr': last_color
|
| 240 |
+
}
|
| 241 |
+
stats['frame_by_frame'].append(frame_info)
|
| 242 |
+
|
| 243 |
+
# Store last frame for final output
|
| 244 |
+
stats['final_frame'] = display_frame.copy()
|
| 245 |
+
|
| 246 |
+
frame_number += 1
|
| 247 |
+
|
| 248 |
+
# Cleanup
|
| 249 |
+
cap.release()
|
| 250 |
+
if video_writer:
|
| 251 |
+
video_writer.release()
|
| 252 |
+
|
| 253 |
+
# Calculate processing time
|
| 254 |
+
processing_time = time.time() - start_time
|
| 255 |
+
stats['processing_time_seconds'] = processing_time
|
| 256 |
+
|
| 257 |
+
return stats
|
| 258 |
+
|
| 259 |
+
def predict_webcam(self, window_name: str = "Live Webcam Inference (Slow)",
|
| 260 |
+
apply_gradcam: bool = False,
|
| 261 |
+
max_frames: Optional[int] = None) -> Dict[str, Any]:
|
| 262 |
+
"""
|
| 263 |
+
Perform real-time inference using webcam.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
window_name: Name of the display window
|
| 267 |
+
apply_gradcam: Whether to generate Grad-CAM (normally False for webcam)
|
| 268 |
+
max_frames: Maximum number of frames to process (None for infinite)
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
Dictionary with webcam inference results
|
| 272 |
+
"""
|
| 273 |
+
cap = cv2.VideoCapture(0)
|
| 274 |
+
if not cap.isOpened():
|
| 275 |
+
raise RuntimeError("Could not open webcam.")
|
| 276 |
+
|
| 277 |
+
# Statistics
|
| 278 |
+
stats = {
|
| 279 |
+
'frames_processed': 0,
|
| 280 |
+
'predictions': [],
|
| 281 |
+
'detected_classes': set(),
|
| 282 |
+
'processing_times': [],
|
| 283 |
+
'live_feed_active': True
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
frame_count = 0
|
| 287 |
+
|
| 288 |
+
print(f"Webcam started. Press 'q' to quit.")
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
while cap.isOpened() and (max_frames is None or frame_count < max_frames):
|
| 292 |
+
ret, frame = cap.read()
|
| 293 |
+
if not ret:
|
| 294 |
+
print("Error: Failed to capture frame.")
|
| 295 |
+
break
|
| 296 |
+
|
| 297 |
+
# Start timing
|
| 298 |
+
start_time = time.time()
|
| 299 |
+
|
| 300 |
+
# Preprocess frame
|
| 301 |
+
input_frame = GradCAM.preprocess_frame(frame)
|
| 302 |
+
|
| 303 |
+
# Get predictions
|
| 304 |
+
predictions = self.model.predict(input_frame, verbose=0)
|
| 305 |
+
label, confidence, prob_array = GradCAM.get_confidence_and_prediction(
|
| 306 |
+
predictions, self.class_labels
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
# End timing
|
| 310 |
+
processing_time = time.time() - start_time
|
| 311 |
+
stats['processing_times'].append(processing_time)
|
| 312 |
+
|
| 313 |
+
# Update stats
|
| 314 |
+
stats['frames_processed'] += 1
|
| 315 |
+
stats['detected_classes'].add(label)
|
| 316 |
+
frame_info = {
|
| 317 |
+
'frame_number': frame_count,
|
| 318 |
+
'label': label,
|
| 319 |
+
'confidence': confidence,
|
| 320 |
+
'processing_time': processing_time
|
| 321 |
+
}
|
| 322 |
+
stats['predictions'].append(frame_info)
|
| 323 |
+
|
| 324 |
+
# Generate Grad-CAM if requested and prediction is fire or smoke
|
| 325 |
+
heatmap = None
|
| 326 |
+
if apply_gradcam and label in ['fire', 'smoke']:
|
| 327 |
+
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 328 |
+
img_array = tf.expand_dims(tf.convert_to_tensor(rgb_image / 255.0), 0)
|
| 329 |
+
class_index = self.reverse_class_map[label]
|
| 330 |
+
|
| 331 |
+
heatmap = self.gradcam.generate_heatmap(img_array, class_index)
|
| 332 |
+
|
| 333 |
+
# Create text overlay
|
| 334 |
+
text_color, bg_color = GradCAM.get_text_overlay_params(label, self.color_map)
|
| 335 |
+
result_text = f"Class: {label} ({confidence*100:.2f}%)"
|
| 336 |
+
display_frame = GradCAM.create_text_overlay(frame, result_text, text_color, bg_color)
|
| 337 |
+
|
| 338 |
+
# Apply Grad-CAM if available
|
| 339 |
+
if heatmap is not None:
|
| 340 |
+
display_frame = self.gradcam.overlay_heatmap(display_frame, heatmap, alpha=0.5)
|
| 341 |
+
|
| 342 |
+
# Display the frame
|
| 343 |
+
cv2.imshow(window_name, display_frame)
|
| 344 |
+
|
| 345 |
+
# Check for quit
|
| 346 |
+
if cv2.waitKey(1) & 0xFF == ord('q'):
|
| 347 |
+
break
|
| 348 |
+
|
| 349 |
+
frame_count += 1
|
| 350 |
+
|
| 351 |
+
finally:
|
| 352 |
+
# Cleanup
|
| 353 |
+
cap.release()
|
| 354 |
+
cv2.destroyAllWindows()
|
| 355 |
+
stats['live_feed_active'] = False
|
| 356 |
+
|
| 357 |
+
return stats
|
| 358 |
+
|
| 359 |
+
def create_gradcam_demo_image(self, image_path: str, class_to_highlight: Optional[str] = None) -> Dict[str, Any]:
|
| 360 |
+
"""
|
| 361 |
+
Create a demonstration image with Grad-CAM for all classes.
|
| 362 |
+
|
| 363 |
+
Args:
|
| 364 |
+
image_path: Path to input image
|
| 365 |
+
class_to_highlight: Optional specific class to highlight (default: auto-select best)
|
| 366 |
+
|
| 367 |
+
Returns:
|
| 368 |
+
Dictionary with demo images for each class
|
| 369 |
+
"""
|
| 370 |
+
# Load and preprocess image
|
| 371 |
+
image = cv2.imread(str(image_path))
|
| 372 |
+
if image is None:
|
| 373 |
+
raise ValueError(f"Could not load image: {image_path}")
|
| 374 |
+
|
| 375 |
+
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 376 |
+
img_array = tf.expand_dims(tf.convert_to_tensor(rgb_image / 255.0), 0)
|
| 377 |
+
|
| 378 |
+
# Get predictions
|
| 379 |
+
predictions = self.model.predict(img_array, verbose=0)
|
| 380 |
+
prob_array = predictions[0]
|
| 381 |
+
|
| 382 |
+
# Determine which class to highlight (default: class with highest probability)
|
| 383 |
+
if class_to_highlight is None:
|
| 384 |
+
max_idx = np.argmax(prob_array)
|
| 385 |
+
class_to_highlight = self.class_labels[max_idx]
|
| 386 |
+
|
| 387 |
+
# Create demo images for each class
|
| 388 |
+
demo_images = {}
|
| 389 |
+
|
| 390 |
+
for class_label in self.class_labels:
|
| 391 |
+
# Get class index
|
| 392 |
+
class_index = self.reverse_class_map[class_label]
|
| 393 |
+
|
| 394 |
+
# Generate heatmap
|
| 395 |
+
heatmap = self.gradcam.generate_heatmap(img_array, class_index)
|
| 396 |
+
|
| 397 |
+
# Overlay heatmap
|
| 398 |
+
if class_label == class_to_highlight:
|
| 399 |
+
# For highlighted class, show the original heatmap
|
| 400 |
+
overlaid = self.gradcam.overlay_heatmap(
|
| 401 |
+
rgb_image, heatmap, alpha=0.6
|
| 402 |
+
)
|
| 403 |
+
overlaid = cv2.cvtColor(overlaid, cv2.COLOR_RGB2BGR)
|
| 404 |
+
else:
|
| 405 |
+
# For other classes, show grayscale heatmap
|
| 406 |
+
heatmap_bgr = cv2.applyColorMap(
|
| 407 |
+
np.uint8(255 * heatmap), cv2.COLORMAP_JET
|
| 408 |
+
)
|
| 409 |
+
overlaid = heatmap_bgr
|
| 410 |
+
|
| 411 |
+
# Create text overlay
|
| 412 |
+
text_color, bg_color = GradCAM.get_text_overlay_params(class_label, self.color_map)
|
| 413 |
+
result_text = f"{class_label} ({prob_array[class_index]*100:.1f}%)"
|
| 414 |
+
demo_images[class_label] = GradCAM.create_text_overlay(
|
| 415 |
+
overlaid, result_text, text_color, bg_color
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
return {
|
| 419 |
+
'original_image': cv2.cvtColor(rgb_image, cv2.COLOR_RGB2BGR),
|
| 420 |
+
'prediction': {
|
| 421 |
+
'predicted_class': self.class_labels[np.argmax(prob_array)],
|
| 422 |
+
'confidence': float(np.max(prob_array)),
|
| 423 |
+
'probabilities': prob_array.tolist()
|
| 424 |
+
},
|
| 425 |
+
'demo_images': demo_images,
|
| 426 |
+
'class_to_highlight': class_to_highlight
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
@classmethod
|
| 430 |
+
def convert_model_format(cls, input_path: str, output_path: str) -> None:
|
| 431 |
+
"""
|
| 432 |
+
Convert model from legacy format to modern Keras format.
|
| 433 |
+
|
| 434 |
+
Args:
|
| 435 |
+
input_path: Path to input model (HDF5 format)
|
| 436 |
+
output_path: Path to save converted model
|
| 437 |
+
"""
|
| 438 |
+
from tensorflow.keras.models import load_model
|
| 439 |
+
|
| 440 |
+
model = load_model(input_path, compile=False)
|
| 441 |
+
model.save(output_path, save_format='keras')
|
| 442 |
+
print(f"Model converted and saved to: {output_path}")
|
| 443 |
+
|
| 444 |
+
@classmethod
|
| 445 |
+
def evaluate_model_on_dataset(cls, model_path: str,
|
| 446 |
+
test_dir: str,
|
| 447 |
+
output_report: Optional[str] = None) -> Dict[str, Any]:
|
| 448 |
+
"""
|
| 449 |
+
Evaluate model performance on a dataset.
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
model_path: Path to model file
|
| 453 |
+
test_dir: Path to test dataset directory (with class subdirectories)
|
| 454 |
+
output_report: Optional path to save evaluation report
|
| 455 |
+
|
| 456 |
+
Returns:
|
| 457 |
+
Dictionary with evaluation results
|
| 458 |
+
"""
|
| 459 |
+
from sklearn.metrics import classification_report, confusion_matrix
|
| 460 |
+
import matplotlib.pyplot as plt
|
| 461 |
+
import seaborn as sns
|
| 462 |
+
|
| 463 |
+
# Load model
|
| 464 |
+
model_wrapper = FireNetModel(model_path)
|
| 465 |
+
model = model_wrapper.load_pretrained_model()
|
| 466 |
+
|
| 467 |
+
# Create data generators
|
| 468 |
+
test_datagen = ImageDataGenerator(rescale=1./255.)
|
| 469 |
+
test_generator = test_datagen.flow_from_directory(
|
| 470 |
+
test_dir,
|
| 471 |
+
target_size=FireNetModel.IMAGE_SIZE,
|
| 472 |
+
batch_size=FireNetModel.BATCH_SIZE,
|
| 473 |
+
class_mode='categorical',
|
| 474 |
+
shuffle=False
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
# Make predictions
|
| 478 |
+
y_pred = model.predict(test_generator, verbose=0)
|
| 479 |
+
y_pred_classes = np.argmax(y_pred, axis=1)
|
| 480 |
+
|
| 481 |
+
# Get true labels
|
| 482 |
+
y_true = test_generator.classes
|
| 483 |
+
|
| 484 |
+
# Generate classification report
|
| 485 |
+
report = classification_report(
|
| 486 |
+
y_true,
|
| 487 |
+
y_pred_classes,
|
| 488 |
+
target_names=FireNetModel.CLASS_LABELS,
|
| 489 |
+
output_dict=True
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
# Generate confusion matrix
|
| 493 |
+
cm = confusion_matrix(y_true, y_pred_classes)
|
| 494 |
+
|
| 495 |
+
# Create and save visualization
|
| 496 |
+
plt.figure(figsize=(10, 8))
|
| 497 |
+
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
| 498 |
+
xticklabels=FireNetModel.CLASS_LABELS,
|
| 499 |
+
yticklabels=FireNetModel.CLASS_LABELS)
|
| 500 |
+
plt.title('Confusion Matrix')
|
| 501 |
+
plt.ylabel('True Label')
|
| 502 |
+
plt.xlabel('Predicted Label')
|
| 503 |
+
|
| 504 |
+
if output_report:
|
| 505 |
+
plt.savefig(output_report, dpi=150, bbox_inches='tight')
|
| 506 |
+
plt.close()
|
| 507 |
+
|
| 508 |
+
# Save report as text file if requested
|
| 509 |
+
if output_report and output_report.endswith('.txt'):
|
| 510 |
+
with open(output_report, 'w') as f:
|
| 511 |
+
f.write("Forest Fire Detection Model Evaluation Report\n")
|
| 512 |
+
f.write("=" * 60 + "\n\n")
|
| 513 |
+
f.write(f"Total samples: {len(y_true)}\n")
|
| 514 |
+
f.write(f"Classes: {', '.join(FireNetModel.CLASS_LABELS)}\n\n")
|
| 515 |
+
f.write("Classification Report:\n")
|
| 516 |
+
f.write(classification_report(y_true, y_pred_classes,
|
| 517 |
+
target_names=FireNetModel.CLASS_LABELS) + "\n\n")
|
| 518 |
+
f.write("Confusion Matrix:\n")
|
| 519 |
+
f.write(str(cm) + "\n")
|
| 520 |
+
|
| 521 |
+
# Add detailed metrics
|
| 522 |
+
f.write("\nDetailed Metrics:\n")
|
| 523 |
+
for i, class_name in enumerate(FireNetModel.CLASS_LABELS):
|
| 524 |
+
precision = report[class_name]['precision']
|
| 525 |
+
recall = report[class_name]['recall']
|
| 526 |
+
f1 = report[class_name]['f1-score']
|
| 527 |
+
support = report[class_name]['support']
|
| 528 |
+
f.write(f"{class_name:10} - Precision: {precision:.3f}, "
|
| 529 |
+
f"Recall: {recall:.3f}, F1-score: {f1:.3f}, "
|
| 530 |
+
f"Support: {support}\n")
|
| 531 |
+
|
| 532 |
+
return {
|
| 533 |
+
'classification_report': report,
|
| 534 |
+
'confusion_matrix': cm.tolist(),
|
| 535 |
+
'total_samples': len(y_true),
|
| 536 |
+
'accuracy': report['accuracy'],
|
| 537 |
+
'macro_avg_f1': report['macro avg']['f1-score'],
|
| 538 |
+
'weighted_avg_f1': report['weighted avg']['f1-score']
|
| 539 |
+
}
|
src/model.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/model.py
|
| 3 |
+
FirenetCNN model definition and utilities
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import numpy as np
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Dict, Tuple, Optional, List
|
| 9 |
+
import tensorflow as tf
|
| 10 |
+
from tensorflow.keras.applications import MobileNetV2
|
| 11 |
+
from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Dropout
|
| 12 |
+
from tensorflow.keras.models import Model, load_model
|
| 13 |
+
from tensorflow.keras.optimizers import Adam
|
| 14 |
+
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
| 15 |
+
class FireNetModel:
|
| 16 |
+
"""FirenetCNN model implementation using MobileNetV2 transfer learning"""
|
| 17 |
+
|
| 18 |
+
# Model configuration constants
|
| 19 |
+
IMAGE_SIZE = (224, 224)
|
| 20 |
+
BATCH_SIZE = 32
|
| 21 |
+
LEARNING_RATE = 0.0001
|
| 22 |
+
EPOCHS = 100
|
| 23 |
+
CLASS_LABELS = ['fire', 'no_fire', 'smoke'] # Model's actual output order
|
| 24 |
+
REVERSE_CLASS_MAP = {'fire': 0, 'no_fire': 1, 'smoke': 2}
|
| 25 |
+
|
| 26 |
+
# Text overlay styling (OpenCV BGR format)
|
| 27 |
+
TEXT_COLOR = {
|
| 28 |
+
'fire': (0, 0, 255), # Red
|
| 29 |
+
'smoke': (0, 255, 255), # Yellow
|
| 30 |
+
'no_fire': (0, 255, 0), # Green
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def __init__(self, model_path: str = 'models/FirenetCNN.keras'):
|
| 34 |
+
"""
|
| 35 |
+
Initialize the FirenetCNN model.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
model_path: Path to the trained Keras model (.keras or .h5 format)
|
| 39 |
+
"""
|
| 40 |
+
self.model_path = Path(model_path)
|
| 41 |
+
self.model = None
|
| 42 |
+
self.last_conv_layer_name = 'out_relu'
|
| 43 |
+
|
| 44 |
+
# Ensure model directory exists
|
| 45 |
+
self.model_path.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
def build_model(input_shape: Tuple[int, int, int] = (224, 224, 3)) -> Model:
|
| 49 |
+
"""
|
| 50 |
+
Build the FirenetCNN architecture.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
input_shape: Input shape for the model (default: 224x224x3)
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Compiled Keras model
|
| 57 |
+
"""
|
| 58 |
+
base_model = MobileNetV2(weights='imagenet', include_top=False,
|
| 59 |
+
input_shape=input_shape)
|
| 60 |
+
|
| 61 |
+
# Freeze the base model layers
|
| 62 |
+
base_model.trainable = False
|
| 63 |
+
|
| 64 |
+
# Classification head
|
| 65 |
+
x = base_model.output
|
| 66 |
+
x = GlobalAveragePooling2D()(x)
|
| 67 |
+
x = Dense(1024, activation='relu')(x)
|
| 68 |
+
x = Dropout(0.5)(x)
|
| 69 |
+
predictions = Dense(3, activation='softmax')(x)
|
| 70 |
+
|
| 71 |
+
model = Model(inputs=base_model.input, outputs=predictions)
|
| 72 |
+
|
| 73 |
+
model.compile(optimizer=Adam(learning_rate=0.0001),
|
| 74 |
+
loss='categorical_crossentropy',
|
| 75 |
+
metrics=['accuracy'])
|
| 76 |
+
|
| 77 |
+
return model
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def create_data_generators(train_dir: str, val_dir: str):
|
| 81 |
+
"""
|
| 82 |
+
Create data generators for training and validation.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
train_dir: Path to training data directory
|
| 86 |
+
val_dir: Path to validation data directory
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Tuple of (train_generator, validation_generator)
|
| 90 |
+
"""
|
| 91 |
+
# Training data with augmentation
|
| 92 |
+
train_datagen = ImageDataGenerator(
|
| 93 |
+
rescale=1./255.,
|
| 94 |
+
rotation_range=40,
|
| 95 |
+
width_shift_range=0.2,
|
| 96 |
+
height_shift_range=0.2,
|
| 97 |
+
shear_range=0.2,
|
| 98 |
+
zoom_range=0.2,
|
| 99 |
+
horizontal_flip=True,
|
| 100 |
+
fill_mode='nearest'
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Validation data without augmentation
|
| 104 |
+
val_test_datagen = ImageDataGenerator(rescale=1./255.)
|
| 105 |
+
|
| 106 |
+
train_generator = train_datagen.flow_from_directory(
|
| 107 |
+
train_dir,
|
| 108 |
+
target_size=FireNetModel.IMAGE_SIZE,
|
| 109 |
+
batch_size=FireNetModel.BATCH_SIZE,
|
| 110 |
+
class_mode='categorical'
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
validation_generator = val_test_datagen.flow_from_directory(
|
| 114 |
+
val_dir,
|
| 115 |
+
target_size=FireNetModel.IMAGE_SIZE,
|
| 116 |
+
batch_size=FireNetModel.BATCH_SIZE,
|
| 117 |
+
class_mode='categorical'
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return train_generator, validation_generator
|
| 121 |
+
|
| 122 |
+
def load_pretrained_model(self, model_path: Optional[str] = None) -> Model:
|
| 123 |
+
"""
|
| 124 |
+
Load a pretrained model from file.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
model_path: Path to model file (optional, uses instance path if None)
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Loaded Keras model
|
| 131 |
+
|
| 132 |
+
Raises:
|
| 133 |
+
FileNotFoundError: If model file does not exist
|
| 134 |
+
"""
|
| 135 |
+
path = Path(model_path) if model_path else self.model_path
|
| 136 |
+
|
| 137 |
+
if not path.exists():
|
| 138 |
+
raise FileNotFoundError(f"Model file not found: {path}")
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
# Try to load as modern Keras .keras format
|
| 142 |
+
self.model = load_model(str(path), compile=False)
|
| 143 |
+
return self.model
|
| 144 |
+
except Exception:
|
| 145 |
+
# Fallback to legacy .h5 format
|
| 146 |
+
if path.suffix == '.h5':
|
| 147 |
+
self.model = load_model(str(path), compile=False)
|
| 148 |
+
return self.model
|
| 149 |
+
raise ValueError(f"Unsupported model format or file not found: {path}")
|
| 150 |
+
|
| 151 |
+
def save_model(self, path: str) -> None:
|
| 152 |
+
"""
|
| 153 |
+
Save the model to file.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
path: Path to save the model
|
| 157 |
+
"""
|
| 158 |
+
if self.model is None:
|
| 159 |
+
raise ValueError("Model not loaded. Call load_model() first.")
|
| 160 |
+
|
| 161 |
+
self.model.save(path)
|
| 162 |
+
|
| 163 |
+
@staticmethod
|
| 164 |
+
def preprocess_image(image_path: str) -> tf.Tensor:
|
| 165 |
+
"""
|
| 166 |
+
Preprocess a single image for inference.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
image_path: Path to the image file
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
Preprocessed image tensor
|
| 173 |
+
"""
|
| 174 |
+
img = tf.keras.utils.load_img(image_path, target_size=FireNetModel.IMAGE_SIZE)
|
| 175 |
+
img_array = tf.keras.utils.img_to_array(img)
|
| 176 |
+
img_array = tf.expand_dims(img_array, 0) # Add batch dimension
|
| 177 |
+
img_array = img_array / 255.0
|
| 178 |
+
return img_array
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def get_model_config() -> Dict:
|
| 182 |
+
"""
|
| 183 |
+
Get model configuration metadata.
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Dictionary with model configuration
|
| 187 |
+
"""
|
| 188 |
+
return {
|
| 189 |
+
'input_shape': (*FireNetModel.IMAGE_SIZE, 3),
|
| 190 |
+
'num_classes': len(FireNetModel.CLASS_LABELS),
|
| 191 |
+
'class_labels': FireNetModel.CLASS_LABELS,
|
| 192 |
+
'reverse_class_map': FireNetModel.REVERSE_CLASS_MAP,
|
| 193 |
+
'learning_rate': FireNetModel.LEARNING_RATE,
|
| 194 |
+
'image_size': FireNetModel.IMAGE_SIZE,
|
| 195 |
+
'batch_size': FireNetModel.BATCH_SIZE,
|
| 196 |
+
'architecture': 'FirenetCNN (MobileNetV2 + custom classifier head)',
|
| 197 |
+
'last_conv_layer': 'out_relu'
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
@classmethod
|
| 201 |
+
def save_model_config(cls, path: str) -> None:
|
| 202 |
+
"""
|
| 203 |
+
Save model configuration to JSON.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
path: Path to save configuration JSON
|
| 207 |
+
"""
|
| 208 |
+
config = cls.get_model_config()
|
| 209 |
+
with open(path, 'w') as f:
|
| 210 |
+
json.dump(config, f, indent=2)
|
| 211 |
+
|
| 212 |
+
@classmethod
|
| 213 |
+
def load_model_config(cls, path: str) -> Dict:
|
| 214 |
+
"""
|
| 215 |
+
Load model configuration from JSON.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
path: Path to configuration JSON
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Dictionary with model configuration
|
| 222 |
+
"""
|
| 223 |
+
with open(path, 'r') as f:
|
| 224 |
+
return json.load(f)
|
src/training.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/training.py
|
| 3 |
+
Training pipeline for FirenetCNN model
|
| 4 |
+
"""
|
| 5 |
+
import tensorflow as tf
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional, Tuple, Dict, Any
|
| 8 |
+
|
| 9 |
+
from .model import FireNetModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def train(
|
| 13 |
+
train_dir: str,
|
| 14 |
+
val_dir: str,
|
| 15 |
+
model_save_path: str = 'models/FirenetCNN.keras',
|
| 16 |
+
epochs: int = 100,
|
| 17 |
+
learning_rate: float = 0.0001,
|
| 18 |
+
batch_size: int = 32,
|
| 19 |
+
image_size: Tuple[int, int] = (224, 224),
|
| 20 |
+
fine_tune: bool = False,
|
| 21 |
+
fine_tune_epochs: int = 10,
|
| 22 |
+
fine_tune_lr: float = 1e-5,
|
| 23 |
+
callback_save_best: bool = True,
|
| 24 |
+
callback_early_stopping: bool = True,
|
| 25 |
+
early_stopping_patience: int = 10,
|
| 26 |
+
) -> Dict[str, Any]:
|
| 27 |
+
"""
|
| 28 |
+
Train the FirenetCNN model.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
train_dir: Path to training data directory
|
| 32 |
+
val_dir: Path to validation data directory
|
| 33 |
+
model_save_path: Path to save the trained model
|
| 34 |
+
epochs: Number of training epochs
|
| 35 |
+
learning_rate: Learning rate for initial training
|
| 36 |
+
batch_size: Training batch size
|
| 37 |
+
image_size: Input image size (height, width)
|
| 38 |
+
fine_tune: Whether to fine-tune the base model after initial training
|
| 39 |
+
fine_tune_epochs: Number of fine-tuning epochs
|
| 40 |
+
fine_tune_lr: Learning rate for fine-tuning
|
| 41 |
+
callback_save_best: Save model with best validation accuracy
|
| 42 |
+
callback_early_stopping: Stop training if validation loss stops improving
|
| 43 |
+
early_stopping_patience: Patience for early stopping
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Dictionary with training history and results
|
| 47 |
+
"""
|
| 48 |
+
# Build model
|
| 49 |
+
model = FireNetModel.build_model(input_shape=(*image_size, 3))
|
| 50 |
+
|
| 51 |
+
# Create data generators
|
| 52 |
+
train_gen, val_gen = FireNetModel.create_data_generators(train_dir, val_dir)
|
| 53 |
+
|
| 54 |
+
# Setup callbacks
|
| 55 |
+
callbacks = []
|
| 56 |
+
|
| 57 |
+
save_path = Path(model_save_path)
|
| 58 |
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
if callback_save_best:
|
| 61 |
+
best_model_path = save_path.with_name(save_path.stem + '_best' + save_path.suffix)
|
| 62 |
+
callbacks.append(tf.keras.callbacks.ModelCheckpoint(
|
| 63 |
+
str(best_model_path),
|
| 64 |
+
monitor='val_accuracy',
|
| 65 |
+
save_best_only=True,
|
| 66 |
+
mode='max',
|
| 67 |
+
verbose=1
|
| 68 |
+
))
|
| 69 |
+
|
| 70 |
+
if callback_early_stopping:
|
| 71 |
+
callbacks.append(tf.keras.callbacks.EarlyStopping(
|
| 72 |
+
monitor='val_loss',
|
| 73 |
+
patience=early_stopping_patience,
|
| 74 |
+
restore_best_weights=True,
|
| 75 |
+
verbose=1
|
| 76 |
+
))
|
| 77 |
+
|
| 78 |
+
# Initial training (frozen base model)
|
| 79 |
+
print(f"Phase 1: Training with frozen base for {epochs} epochs...")
|
| 80 |
+
history = model.fit(
|
| 81 |
+
train_gen,
|
| 82 |
+
epochs=epochs,
|
| 83 |
+
validation_data=val_gen,
|
| 84 |
+
callbacks=callbacks,
|
| 85 |
+
verbose=1
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Fine-tuning phase
|
| 89 |
+
if fine_tune:
|
| 90 |
+
print(f"\nPhase 2: Fine-tuning for {fine_tune_epochs} epochs...")
|
| 91 |
+
|
| 92 |
+
# Unfreeze the base model
|
| 93 |
+
base_model = None
|
| 94 |
+
for layer in model.layers:
|
| 95 |
+
if hasattr(layer, 'layers') and len(layer.layers) > 50:
|
| 96 |
+
base_model = layer
|
| 97 |
+
break
|
| 98 |
+
|
| 99 |
+
if base_model is not None:
|
| 100 |
+
base_model.trainable = True
|
| 101 |
+
|
| 102 |
+
# Recompile with lower learning rate
|
| 103 |
+
model.compile(
|
| 104 |
+
optimizer=tf.keras.optimizers.Adam(learning_rate=fine_tune_lr),
|
| 105 |
+
loss='categorical_crossentropy',
|
| 106 |
+
metrics=['accuracy']
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Continue training
|
| 110 |
+
history_fine = model.fit(
|
| 111 |
+
train_gen,
|
| 112 |
+
epochs=fine_tune_epochs,
|
| 113 |
+
validation_data=val_gen,
|
| 114 |
+
callbacks=callbacks,
|
| 115 |
+
verbose=1
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Merge histories
|
| 119 |
+
for key in history_fine.history:
|
| 120 |
+
history.history[key].extend(history_fine.history[key])
|
| 121 |
+
|
| 122 |
+
# Save final model
|
| 123 |
+
model.save(str(save_path))
|
| 124 |
+
print(f"\nModel saved to: {save_path}")
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
'history': history.history,
|
| 128 |
+
'epochs_completed': len(history.history['accuracy']),
|
| 129 |
+
'final_train_acc': history.history['accuracy'][-1],
|
| 130 |
+
'final_val_acc': history.history['val_accuracy'][-1],
|
| 131 |
+
'model_path': str(save_path)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == '__main__':
|
| 136 |
+
import argparse
|
| 137 |
+
|
| 138 |
+
parser = argparse.ArgumentParser(description='Train FirenetCNN model')
|
| 139 |
+
parser.add_argument('--train-dir', default='data/forestfire-classifier-dataset/train',
|
| 140 |
+
help='Training data directory')
|
| 141 |
+
parser.add_argument('--val-dir', default='data/forestfire-classifier-dataset/val',
|
| 142 |
+
help='Validation data directory')
|
| 143 |
+
parser.add_argument('--model-path', default='models/FirenetCNN.keras',
|
| 144 |
+
help='Path to save trained model')
|
| 145 |
+
parser.add_argument('--epochs', type=int, default=100,
|
| 146 |
+
help='Number of training epochs')
|
| 147 |
+
parser.add_argument('--fine-tune', action='store_true',
|
| 148 |
+
help='Enable fine-tuning phase')
|
| 149 |
+
parser.add_argument('--fine-tune-epochs', type=int, default=10,
|
| 150 |
+
help='Number of fine-tuning epochs')
|
| 151 |
+
|
| 152 |
+
args = parser.parse_args()
|
| 153 |
+
|
| 154 |
+
results = train(
|
| 155 |
+
train_dir=args.train_dir,
|
| 156 |
+
val_dir=args.val_dir,
|
| 157 |
+
model_save_path=args.model_path,
|
| 158 |
+
epochs=args.epochs,
|
| 159 |
+
fine_tune=args.fine_tune,
|
| 160 |
+
fine_tune_epochs=args.fine_tune_epochs
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
print(f"\nTraining complete!")
|
| 164 |
+
print(f"Final train accuracy: {results['final_train_acc']:.4f}")
|
| 165 |
+
print(f"Final val accuracy: {results['final_val_acc']:.4f}")
|