Así que lo de la cámara por fin funciona, después de una primera versión que técnicamente también "funcionaba", si tu definición de funcionar incluye enviarme correos cuarenta y tres veces antes del almuerzo.
Tengo una webcam vieja sujeta a un estante apuntando a la puerta de entrada, y un script corriendo en una máquina que tengo de sobra que vigila la señal. En el momento en que algo se mueve por el encuadre, captura una foto y me la envía por correo. No es una empresa de seguridad, no es una suscripción, es solo Python haciendo exactamente lo único que le pedí, con el tiempo.
por qué quería esto en primer lugar
Trabajo en el piso de arriba la mayoría de los días, y la puerta está abajo, lo bastante lejos como para que un repartidor tocando o alguien subiendo no me llegue de forma fiable. Los paquetes se quedaron fuera más tiempo del que deberían un par de veces, una vez con un clima que no le hizo ningún favor a la caja. La solución no necesitaba ser ingeniosa. Solo necesitaba avisarme en el instante en que alguien estuviera ahí.
intento uno, que no era eso
Mi primera versión comparaba dos fotogramas directamente, en color, píxel a píxel, y sumaba cuánto diferían.
import cv2
cap = cv2.VideoCapture(0)
_, prev = cap.read()
while True:
_, curr = cap.read()
diff = cv2.absdiff(prev, curr)
total_diff = diff.sum()
if total_diff > 500000:
send_alert(curr)
prev = currEsto funcionó bien durante unos seis minutos de pruebas a la luz del día y luego se vino abajo en cuanto el sol se movió detrás de una nube. El brillo de todo el fotograma cambió un poco, cada píxel cambió una pequeña cantidad, y sumado a lo largo de toda una imagen en color eso es más que suficiente para superar cualquier umbral que eligiera. Recibí un correo. Luego otro cuatro segundos después porque la nube seguía moviéndose. Luego uno porque los faros de un coche barrieron el porche esa noche. Ninguno de esos era una persona. Todos eran "todo el fotograma se puso un poco más claro o más oscuro".
lo que en realidad busqué en Google
"opencv motion detection false positive lighting change" me llevó a un tutorial de pyimagesearch que, por lo visto, es de donde medio internet aprende detección de movimiento básica, y leerlo me hizo sentir un poco tonto de una manera útil. Dos cosas que se me habían pasado por completo:
- Convertir a escala de grises primero y difuminar antes de comparar, para que el ruido de un solo píxel y los pequeños parpadeos de luz se suavicen en vez de contarse
- En lugar de sumar diferencias de píxeles en bruto, umbralizar la diferencia en una máscara en blanco y negro, luego mirar las regiones conectadas (contornos) y solo preocuparse por las que superan cierto tamaño mínimo
Esa segunda parte es la que en realidad importaba. Una nube pasando cambia el brillo en todas partes un poquito, lo cual tras umbralizar prácticamente desaparece porque un cambio suficientemente pequeño nunca cruza el umbral en primer lugar. Una persona atravesando el encuadre cambia una zona grande y contigua de píxeles bastante. Una forma completamente distinta una vez que lo miras como regiones en vez de un solo número sumado.
la versión que de verdad funciona
El manejo del fondo primero. En lugar de comparar fotograma contra fotograma, tomo un fotograma como base y lo difumino:
import cv2
cap = cv2.VideoCapture(0)
_, first_frame = cap.read()
baseline = cv2.GaussianBlur(cv2.cvtColor(first_frame, cv2.COLOR_BGR2GRAY), (21, 21), 0)Luego, por cada fotograma, difumino y comparo contra esa base en lugar del fotograma anterior:
def detect_regions(frame, baseline, min_area=2000):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
diff = cv2.absdiff(baseline, gray)
thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
return [c for c in contours if cv2.contourArea(c) > min_area]Y el bucle principal, que también actualiza lentamente la base para que la deriva de luz a largo plazo (de la mañana a la tarde, no solo una nube pasajera) no acabe haciendo que todo parezca movimiento para siempre:
while True:
_, frame = cap.read()
regions = detect_regions(frame, baseline)
if regions:
send_alert(frame)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
baseline = cv2.addWeighted(gray.astype("float"), 0.05, baseline.astype("float"), 0.95, 0).astype("uint8")Esa línea de addWeighted está haciendo un promedio móvil lento, empujando la base un 5% hacia el fotograma actual en cada vuelta en lugar de reemplazarla del todo. Lo bastante lento para que una persona real pasando no se absorba en la base antes de disparar la alerta, lo bastante gradual para que el sol cruzando el cielo a lo largo de horas no acabe leyéndose como un gran bulto de movimiento permanente.
un tiempo de espera, porque los proveedores de correo tienen opiniones
La otra parte que me llevó una tarde: incluso con la detección bien hecha, una sola persona pasando frente a la puerta durante tres segundos podía disparar cinco correos distintos al cruzar varios fotogramas. Una marca de tiempo simple de espera lo arregló.
import time
last_alert = 0
COOLDOWN_SECONDS = 60
def maybe_alert(frame):
global last_alert
if time.time() - last_alert < COOLDOWN_SECONDS:
return
send_alert(frame)
last_alert = time.time()la parte del correo
smtplib y email.message no son librerías emocionantes (un poco toscas la verdad, la API se siente como si no hubiera cambiado desde 2003, cosa que en su mayor parte no ha hecho), pero consiguen meter un mensaje con un JPEG adjunto en mi bandeja de entrada en unas cuatro líneas una vez resuelto el boilerplate.
import smtplib
from email.message import EmailMessage
def send_alert(image_bytes):
msg = EmailMessage()
msg["Subject"] = "motion detected"
msg["From"] = "camera@example.com"
msg["To"] = "bjorn@example.com"
msg.set_content("Something moved in frame.")
msg.add_attachment(image_bytes, maintype="image", subtype="jpeg", filename="frame.jpg")
with smtplib.SMTP_SSL("smtp.example.com", 465) as server:
server.login("camera@example.com", "app-password-not-real")
server.send_message(msg)
Las primeras alertas reales fueron directas a spam, algo que no había tenido en cuenta en absoluto. Resulta que una cuenta de correo que solo envía, nunca recibe, sin un registro SPF bien configurado, se parece exactamente a algo de lo que un filtro de spam debería sospechar. Hicieron falta un registro SPF y DKIM en el dominio emisor antes de que Gmail dejara de enterrar en silencio mis propias alertas de mí mismo.
el error de despliegue que más vergüenza me dio
Durante la primera semana esto "corría sin supervisión" dentro de una ventana de terminal en mi portátil, en una sesión de inicio de sesión normal. Lo cual significó que la única vez que de verdad importó, una entrega a una hora en la que resultó que tenía el portátil cerrado, todo el asunto se había ido a dormir junto con la tapa y nunca vio nada. Sin supervisión estaba haciendo mucho trabajo en esa frase que la configuración en realidad no respaldaba. Ahora es un servicio de systemd en la máquina de sobra, se reinicia si falla, sobrevive a que yo cierre lo que sea.
por qué esta se sintió diferente
La mayor parte de lo que había construido antes de esto era autocontenido: una app de tareas, una herramienta de PDF, un scraper que corre una vez e imprime algo. Esta corre de forma continua, para siempre, sin supervisión (de verdad, ahora), y tiene que ser correcta de verdad, o bien se pierde lo que me importa o me bombardea con falsas alarmas. La primera semana hizo ambas cosas, lo cual fue molesto de dos maneras completamente distintas.
También hay una versión de esta idea que aún no he construido, donde distingue una silueta con forma de persona de cualquier otra cosa, en lugar de cualquier región en movimiento suficientemente grande. El área de contorno me lleva la mayor parte del camino, pero un perro grande o un arbusto que se mece tras una tormenta superan ambos el mismo umbral de tamaño que una persona. Ese es un problema genuinamente más difícil, más cercano a la detección de objetos real que a la diferencia de fotogramas, y está en la lista, debajo de leerme de verdad la lección de E/S de archivos de CS50P en lugar de solo hojearla.
qué sigue
Un tiempo de espera que se adapte en lugar de un fijo de sesenta segundos, para que una persona parada en la puerta hablando no vuelva a disparar la alerta en cuanto expire. Y, con el tiempo, el problema de persona contra polilla contra arbusto, que sospecho que termina conmigo aprendiendo por fin un modelo de detección de objetos como es debido en lugar de apoyarme en la diferencia de fotogramas para siempre.