init: camera server Flask MJPEG
This commit is contained in:
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# pi-test-cam — Contexte projet
|
||||||
|
|
||||||
|
## Matériel
|
||||||
|
- Raspberry Pi Zero 2W (pi-wireclaw, 192.168.1.132)
|
||||||
|
- Caméra OV5647 connectée sur port CSI
|
||||||
|
- OS : Raspberry Pi OS Bookworm Lite (SSH uniquement)
|
||||||
|
|
||||||
|
## Rôle
|
||||||
|
Serveur de test caméra pour le projet WireClaw (drone d'inspection industrielle).
|
||||||
|
En prod, le Pi Zero rejoindra le hotspot WireClaw (10.42.0.0/24) créé par le Jetson.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Python 3, Flask, OpenCV
|
||||||
|
- Packages système apt (pas de virtualenv)
|
||||||
|
- Fichiers dans ~/pi-test-cam/
|
||||||
|
|
||||||
|
## Endpoints Flask (port 5000)
|
||||||
|
- GET /stream → flux MJPEG
|
||||||
|
- GET /snapshot → photo JPEG
|
||||||
|
- GET /settings → lire les réglages
|
||||||
|
- POST /settings → modifier brightness, contrast, saturation
|
||||||
|
|
||||||
|
## Réseau WireClaw complet
|
||||||
|
- Jetson : 192.168.1.84 / 10.42.0.1 (FastAPI :8765, hotspot)
|
||||||
|
- ESP32 : 192.168.1.122 / 10.42.0.77 (MAVLink relais)
|
||||||
|
- Pi Zero : 192.168.1.132 / 10.42.0.XX (caméra)
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
git.syoul.fr/nicoboy/pi-test-cam
|
||||||
145
camera_server.py
Normal file
145
camera_server.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
from flask import Flask, Response, request, jsonify
|
||||||
|
import cv2
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Paramètres image modifiables à chaud
|
||||||
|
settings = {
|
||||||
|
"brightness": 0, # -100 à +100
|
||||||
|
"contrast": 1.0, # 0.5 à 3.0
|
||||||
|
"saturation": 1.0, # 0.0 à 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
frame_lock = threading.Lock()
|
||||||
|
current_frame = None
|
||||||
|
|
||||||
|
def apply_settings(frame):
|
||||||
|
# Contrast + Brightness
|
||||||
|
frame = cv2.convertScaleAbs(
|
||||||
|
frame,
|
||||||
|
alpha=settings["contrast"],
|
||||||
|
beta=settings["brightness"]
|
||||||
|
)
|
||||||
|
# Saturation via HSV
|
||||||
|
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype("float32")
|
||||||
|
hsv[:, :, 1] *= settings["saturation"]
|
||||||
|
hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 255)
|
||||||
|
frame = cv2.cvtColor(hsv.astype("uint8"), cv2.COLOR_HSV2BGR)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def capture_loop():
|
||||||
|
global current_frame
|
||||||
|
# Sur Pi Zero avec OV5647, /dev/video0
|
||||||
|
cam = cv2.VideoCapture(0)
|
||||||
|
cam.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||||
|
cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||||
|
cam.set(cv2.CAP_PROP_FPS, 15)
|
||||||
|
|
||||||
|
if not cam.isOpened():
|
||||||
|
print("ERREUR : impossible d'ouvrir la caméra")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Caméra initialisée")
|
||||||
|
while True:
|
||||||
|
ok, frame = cam.read()
|
||||||
|
if ok:
|
||||||
|
frame = apply_settings(frame)
|
||||||
|
with frame_lock:
|
||||||
|
current_frame = frame.copy()
|
||||||
|
time.sleep(0.066) # ~15fps
|
||||||
|
|
||||||
|
def generate_mjpeg():
|
||||||
|
while True:
|
||||||
|
with frame_lock:
|
||||||
|
if current_frame is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
_, jpeg = cv2.imencode(
|
||||||
|
".jpg", current_frame,
|
||||||
|
[cv2.IMWRITE_JPEG_QUALITY, 70]
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
b"--frame\r\n"
|
||||||
|
b"Content-Type: image/jpeg\r\n\r\n"
|
||||||
|
+ jpeg.tobytes()
|
||||||
|
+ b"\r\n"
|
||||||
|
)
|
||||||
|
time.sleep(0.066)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<head><title>pi-test-cam</title></head>
|
||||||
|
<body style="background:#111;color:#eee;font-family:monospace;padding:20px">
|
||||||
|
<h2>pi-test-cam</h2>
|
||||||
|
<img src="/stream" width="640" height="360"
|
||||||
|
style="border:1px solid #444;border-radius:4px"><br><br>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:20px;margin-top:10px">
|
||||||
|
<label>Brightness
|
||||||
|
<input type="range" min="-100" max="100" value="0"
|
||||||
|
oninput="update('brightness', this.value)">
|
||||||
|
</label>
|
||||||
|
<label>Contrast
|
||||||
|
<input type="range" min="50" max="300" value="100"
|
||||||
|
oninput="update('contrast', this.value/100)">
|
||||||
|
</label>
|
||||||
|
<label>Saturation
|
||||||
|
<input type="range" min="0" max="300" value="100"
|
||||||
|
oninput="update('saturation', this.value/100)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<a href="/snapshot" style="color:#4af">📸 Snapshot</a>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function update(key, value) {
|
||||||
|
fetch('/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({[key]: parseFloat(value)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
@app.route("/stream")
|
||||||
|
def stream():
|
||||||
|
return Response(
|
||||||
|
generate_mjpeg(),
|
||||||
|
mimetype="multipart/x-mixed-replace; boundary=frame"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/snapshot")
|
||||||
|
def snapshot():
|
||||||
|
with frame_lock:
|
||||||
|
if current_frame is None:
|
||||||
|
return "Pas de frame disponible", 503
|
||||||
|
_, jpeg = cv2.imencode(
|
||||||
|
".jpg", current_frame,
|
||||||
|
[cv2.IMWRITE_JPEG_QUALITY, 95]
|
||||||
|
)
|
||||||
|
return Response(jpeg.tobytes(), mimetype="image/jpeg")
|
||||||
|
|
||||||
|
@app.route("/settings", methods=["GET", "POST"])
|
||||||
|
def handle_settings():
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.json
|
||||||
|
for k in ["brightness", "contrast", "saturation"]:
|
||||||
|
if k in data:
|
||||||
|
settings[k] = float(data[k])
|
||||||
|
return jsonify({"ok": True, "settings": settings})
|
||||||
|
return jsonify(settings)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
t = threading.Thread(target=capture_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
time.sleep(2)
|
||||||
|
print("Serveur démarré sur http://0.0.0.0:5000")
|
||||||
|
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
flask
|
||||||
|
opencv-python
|
||||||
Reference in New Issue
Block a user