La idea es sencilla tomar una foto, rotar la cámara ciertos pasos, volver a tomar una foto y repetir el proceso hasta lograr tener suficientes fotos para construir una imagen panorámica.
Mi intención es poder realizar timelapse panorámicos repitiendo este método en intervalos de 15 minutos.
Voy a comenzar diciendo que se puede mejorar, y bastante, pero es más una prueba de concepto que un producto terminado; y como prototipo al menos funciona bastante bien.
El circuito es bastante sencillo:
Los componentes utilizados son los siguientes en la lista provistos por DigitSpace:
- ESP32-CAM
- 28BYJ-48 Stepper Motor
- Condensador
- USB Breakout
- Partes impresas (link abajo)
En el siguiente post se pueden encontrar el detalle de la construcción/ensamble del hardware.
Tome de base el programa que tenia previamente para obtener imágenes con el ESP32-CAM:
https://github.com/gsampallo/esp32cam-python
El programa esta armado sobre Python 3.7.4 y OpenCV 4.1.1, utilice docker para evitar la instalación de OpenCV y las librerías, el detalle de la instalación y build de las imágenes pueden encontrarlo aquí.
Comencemos con el programa que corre el ESP32-CAM, se debe ocupar de tres tareas:
- Tomar imágenes.
- Mover el motor para reposicionar la cámara.
- Mantener la posición de la cámara, para pode reubicarla.
El punto 1 lo tenemos resuelto en el programa linkeado anteriormente.
Sobre el punto 2 debemos:
const int motorPin1 = 12;
const int motorPin2 = 13;
const int motorPin3 = 15;
const int motorPin4 = 14;
int origin = 0;
int motorSpeed = 1200; //spped
int stepCounter = 0; //step counter
int stepsPerRev = 512; //number of steps per lap
const int numSteps = 8;
const int stepsLookup[8] = { B1000, B1100, B0100, B0110, B0010, B0011, B0001, B1001 };
Se definen los GPIO donde estan conectados el ULN2003. de manera que motorPïn1 este conectado a IN1.
La variable origin indica la posición actual del motor luego de que se haya llamada a cada una de las funciones que mueven el motor.
void clockwise() {
Serial.println("Girando derecha");
stepCounter++;
if (stepCounter >= numSteps) stepCounter = 0;
setOutput(stepCounter);
}
void anticlockwise() {
Serial.println("Girando izquierda");
stepCounter--;
if (stepCounter < 0) stepCounter = numSteps - 1;
setOutput(stepCounter);
}
void setOutput(int step) {
digitalWrite(motorPin1, bitRead(stepsLookup[step], 0));
digitalWrite(motorPin2, bitRead(stepsLookup[step], 1));
digitalWrite(motorPin3, bitRead(stepsLookup[step], 2));
digitalWrite(motorPin4, bitRead(stepsLookup[step], 3));
}
void moveLeft() {
for (int i = 0; i < 128; i++) { //tenia 256
anticlockwise();
delayMicroseconds(motorSpeed);
}
}
void moveRight() {
for (int i = 0; i < 128; i++) {
clockwise();
delayMicroseconds(motorSpeed);
}
}
Con las funciones definidas anteriormente combinadas podemos mover el motor en ambas direcciones. Al ser un motor paso a paso debemos encender y apagar cada una de las bobinas del mismo de manera alternada; para ello utilizamos la función moveRight/moveLeft combinada con clockwise y la cantidad de pasos. En este caso utilizamos 128 paso en cada iteración. Si aumentamos ese valor el angulo entre giro y giro sera mayor.
En el método setup adicionalmente a lo que ya teníamos se suma:
pinMode(motorPin1, OUTPUT);
pinMode(motorPin2, OUTPUT);
pinMode(motorPin3, OUTPUT);
pinMode(motorPin4, OUTPUT);
Se definen que los GPIO que controlan al motor sean salidas.
server.on("/info", info);
server.on("/moverIzquierda", moverIzquierda);
server.on("/moverDerecha", moverDerecha);
initialMove();
/info llama a la función info.
void info() {
server.send(200, "text/plain", "{ \"pos\":\""+String(origin)+"\"}");
}
Esta función devuelve un JSON con la posición actual del motor.
El dispositivo al no tener un mecanismo para determinar donde esta el HOME, toma el valor 0 (Home) al recibir alimentación, es decir que cuando lo prendemos toma esa posición como Home. Es importante al momento de que no se enreden los cables.
/moverIzquierda mueve la cámara hacia la izquierda. Dependerá de como hayamos conectados los cables (GPIO 12,13,15,14) al ULN2003 para determinar cual es derecha y cual es izquierda. Si se invierten cambia la dirección.
void moverIzquierda() {
moveLeft();
origin--;
off();
info();
}
Esta función llama a moveLeft() que mueve la cámara 128 pasos, luego resta en 1 a la posición, pone en 0 la salida de los GPIO conectados al ULN2003 (para ahorrar energía, puesto que la placa tiene unos leds) y por ultimo llama a la función info() para devolver la posición actual.
Lo mismo realiza moverDerecha() pero en sentido inverso.
Con una combinación de las funciones info() y moverDerecha() y moverIzquierda() podemos controlar el movimiento de la camara y saber donde esta ubicada, cumplimos con el punto 3.
Respecto al programa en Python, debe cumplir con dos tareas:
- Mover la cámara a la posición Home antes de comenzar a tomar fotos.
- Mover la cámara, aguardar que este en posición y tomar la foto.
- Combinar todas las fotos en una sola ( stitch ).
Dentro de los parámetros que se definen, quizás el mas importante es url y nPhoto:
url = "http://192.168.1.111/"
nPhoto = 16
Es importante reemplazar url por la dirección correcta, dependerá de lo que tome dentro de la red.Otro parámetro es nPhoto es la cantidad de fotos que se tomaran para armar la composición final.
Para cumplir con el punto 1, nos ayudamos de la función moveHome(), cuya tarea es mover la cámara hasta la posición 0 (Home).
#According to the position move the camera to home position
def moveHome():
response = requests.get(info)
data = json.loads(response.content.decode())
pos = int(data["pos"])
print(pos)
while(pos > 0):
response = requests.get(left)
data = json.loads(response.content.decode())
pos = int(data["pos"])
Luego que la cámara este en Home, utilizamos moveRight() para reposicionar la cámara para una nueva foto:
#Move the camera to right
def moveRight():
response = requests.get(right)
data = json.loads(response.content.decode())
pos = int(data["pos"])
print(pos)
Para tomar una nueva foto utilizaremos takePhoto(n), la cual descarga la foto (gracias a /cam), la rota 270°, debido a que el ESP32-CAM esta en posición vertical y luego la guarda dentro de la carpeta temp.
def takePhoto(n): #Take the photo, rotate 270 degrees and save in temp folder
response = requests.get(cam)
img = Image.open(BytesIO(response.content))
transposed = img.transpose(Image.ROTATE_270)
transposed.save(temp+str(n)+".jpg")
Para cumplir el punto dos, utilizamos una combinación de las anteriores:
moveHome()
n = 0
while(n < nPhoto):
takePhoto(n)
moveRight()
n += 1
En este punto tenemos todas las imagenes parciales dentro de la carpeta /temp, es momento de cumplir con el punto 3 de unir ( stitch ) las mismas para formar una sola.
#Read each of the images and then put it in the array
n = 0
imgs = []
while(n < nPhoto):
archivo = "temp/"+str(n)+".jpg"
print(archivo)
img = cv2.imread(archivo)
imgs.append(img)
n += 1
#Stitch the images on the array, and save the output image.
stitcher = cv2.Stitcher.create(cv2.Stitcher_PANORAMA)
status, pano = stitcher.stitch(imgs)
if status != cv2.Stitcher_OK:
print("Can't stitch images, error code = %d" % status)
sys.exit(-1)
cv2.imwrite(output+getFileName(), pano);
La foto final sera almacenada en la carpeta output, tiene como nombre la fecha+hora.