├── README.md ├── esp32 └── websocket_camera_stream │ └── websocket_camera_stream.ino ├── images ├── esp_ai_thinker_camera.jpg └── headline.jpg └── python ├── placeholder.jpg ├── receive_stream.py └── send_image_stream.py /README.md: -------------------------------------------------------------------------------- 1 | ![headline](/images/headline.jpg) 2 | 3 | 4 | # esp32_camera_webstream 5 | Bringing the ESP32 camera video stream to the web! 6 | 7 | Want to stay updated or participate? Join my [Discord](https://discord.com/invite/rerCyqAcrw)! 8 | 9 | The Arduino ESP32-Camera test sketch only lets you use the stream on your local network. To get the stream to the web, you need a bit more... 10 | 11 | This collection of scripts consists of: 12 | - Arduino code for ESP32 camera module (AI Thinker CAM) `websocket_camera_stream.ino` 13 | - Python code to receive the images via websockets with `receive_stream.py` 14 | - Python code to push the most recent image to a website with `send_image_stream.py` 15 | 16 | 17 | # Why is this cool? 18 | I havent found a working repository that streams ESP32 camera images in real time to a web backend. This sovles this issue. 19 | 20 | # How to run? 21 | 1. Open the ESP32 code in your Arduino IDE, install all missing libraries, change the `ssid`, `password` and `websockets_server_host`. 22 | Upload the code to you ESP32 AI Thinker Cam board. Please test the Arduino camera example before you test this code! 23 | 24 | 2. Install the missing python requirements using pip: `pip install pillow websockets flask asyncio` 25 | 26 | 3. Run `python receive_stream.py` 27 | You should get a constant stream of numbers (sizes of images). The image.jpg in the directory is always the latest received image. 28 | 29 | 4. Open a second terminal and run `python send_image_stream.py` 30 | You should get a response by flask with an IP and port to enter in your browser. 31 | 32 | Now enjoy your fresh live stream! 📺 33 | 34 | 35 | 36 | # Known Issues 37 | ### Browsers don't like broken images. 38 | This is solved using the placeholder.jpg. It just replaces the image, if the backend receives a broken frame to prevent the browser from freezing the stream. 39 | 40 | ### You have to have the right board. 41 | There are many ESP32 Camera modules. The defined pins in `websocket_camera_stream.ino` only work with the AI Thinker Cam. Change this, if you have a different board. The only tested camera is currently the OV2640. 42 | 43 | 44 | ## Video! 45 | [![LINK TO VIDEO](https://img.youtube.com/vi/cdjgs48OQ6E/0.jpg)](https://www.youtube.com/watch?v=cdjgs48OQ6E) 46 | -------------------------------------------------------------------------------- /esp32/websocket_camera_stream/websocket_camera_stream.ino: -------------------------------------------------------------------------------- 1 | #include "esp_camera.h" 2 | #include 3 | #include 4 | #include "esp_timer.h" 5 | #include "img_converters.h" 6 | #include "fb_gfx.h" 7 | #include "soc/soc.h" //disable brownout problems 8 | #include "soc/rtc_cntl_reg.h" //disable brownout problems 9 | #include "driver/gpio.h" 10 | 11 | 12 | // configuration for AI Thinker Camera board 13 | #define PWDN_GPIO_NUM 32 14 | #define RESET_GPIO_NUM -1 15 | #define XCLK_GPIO_NUM 0 16 | #define SIOD_GPIO_NUM 26 17 | #define SIOC_GPIO_NUM 27 18 | #define Y9_GPIO_NUM 35 19 | #define Y8_GPIO_NUM 34 20 | #define Y7_GPIO_NUM 39 21 | #define Y6_GPIO_NUM 36 22 | #define Y5_GPIO_NUM 21 23 | #define Y4_GPIO_NUM 19 24 | #define Y3_GPIO_NUM 18 25 | #define Y2_GPIO_NUM 5 26 | #define VSYNC_GPIO_NUM 25 27 | #define HREF_GPIO_NUM 23 28 | #define PCLK_GPIO_NUM 22 29 | 30 | 31 | 32 | const char* ssid = "network-name"; // CHANGE HERE 33 | const char* password = "network-password"; // CHANGE HERE 34 | 35 | const char* websockets_server_host = "192.168.1.149"; //CHANGE HERE 36 | const uint16_t websockets_server_port = 3001; // OPTIONAL CHANGE 37 | 38 | camera_fb_t * fb = NULL; 39 | size_t _jpg_buf_len = 0; 40 | uint8_t * _jpg_buf = NULL; 41 | uint8_t state = 0; 42 | 43 | using namespace websockets; 44 | WebsocketsClient client; 45 | 46 | void onMessageCallback(WebsocketsMessage message) { 47 | Serial.print("Got Message: "); 48 | Serial.println(message.data()); 49 | } 50 | 51 | esp_err_t init_camera() { 52 | camera_config_t config; 53 | config.ledc_channel = LEDC_CHANNEL_0; 54 | config.ledc_timer = LEDC_TIMER_0; 55 | config.pin_d0 = Y2_GPIO_NUM; 56 | config.pin_d1 = Y3_GPIO_NUM; 57 | config.pin_d2 = Y4_GPIO_NUM; 58 | config.pin_d3 = Y5_GPIO_NUM; 59 | config.pin_d4 = Y6_GPIO_NUM; 60 | config.pin_d5 = Y7_GPIO_NUM; 61 | config.pin_d6 = Y8_GPIO_NUM; 62 | config.pin_d7 = Y9_GPIO_NUM; 63 | config.pin_xclk = XCLK_GPIO_NUM; 64 | config.pin_pclk = PCLK_GPIO_NUM; 65 | config.pin_vsync = VSYNC_GPIO_NUM; 66 | config.pin_href = HREF_GPIO_NUM; 67 | config.pin_sscb_sda = SIOD_GPIO_NUM; 68 | config.pin_sscb_scl = SIOC_GPIO_NUM; 69 | config.pin_pwdn = PWDN_GPIO_NUM; 70 | config.pin_reset = RESET_GPIO_NUM; 71 | config.xclk_freq_hz = 20000000; 72 | config.pixel_format = PIXFORMAT_JPEG; 73 | 74 | // parameters for image quality and size 75 | config.frame_size = FRAMESIZE_VGA; // FRAMESIZE_ + QVGA|CIF|VGA|SVGA|XGA|SXGA|UXGA 76 | config.jpeg_quality = 15; //10-63 lower number means higher quality 77 | config.fb_count = 2; 78 | 79 | 80 | // Camera init 81 | esp_err_t err = esp_camera_init(&config); 82 | if (err != ESP_OK) { 83 | Serial.printf("camera init FAIL: 0x%x", err); 84 | return err; 85 | } 86 | sensor_t * s = esp_camera_sensor_get(); 87 | s->set_framesize(s, FRAMESIZE_VGA); 88 | Serial.println("camera init OK"); 89 | return ESP_OK; 90 | }; 91 | 92 | 93 | esp_err_t init_wifi() { 94 | WiFi.begin(ssid, password); 95 | Serial.println("Wifi init "); 96 | while (WiFi.status() != WL_CONNECTED) { 97 | delay(500); 98 | Serial.print("."); 99 | } 100 | Serial.println(""); 101 | Serial.println("WiFi OK"); 102 | Serial.println("connecting to WS: "); 103 | client.onMessage(onMessageCallback); 104 | bool connected = client.connect(websockets_server_host, websockets_server_port, "/"); 105 | if (!connected) { 106 | Serial.println("WS connect failed!"); 107 | Serial.println(WiFi.localIP()); 108 | state = 3; 109 | return ESP_FAIL; 110 | } 111 | if (state == 3) { 112 | return ESP_FAIL; 113 | } 114 | 115 | Serial.println("WS OK"); 116 | client.send("hello from ESP32 camera stream!"); 117 | return ESP_OK; 118 | }; 119 | 120 | 121 | void setup() { 122 | WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); 123 | 124 | Serial.begin(115200); 125 | Serial.setDebugOutput(true); 126 | 127 | init_camera(); 128 | init_wifi(); 129 | } 130 | 131 | void loop() { 132 | if (client.available()) { 133 | camera_fb_t *fb = esp_camera_fb_get(); 134 | if (!fb) { 135 | Serial.println("img capture failed"); 136 | esp_camera_fb_return(fb); 137 | ESP.restart(); 138 | } 139 | client.sendBinary((const char*) fb->buf, fb->len); 140 | Serial.println("image sent"); 141 | esp_camera_fb_return(fb); 142 | client.poll(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /images/esp_ai_thinker_camera.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neumi/esp32_camera_webstream/dd4c48adae8f36bfa32ee53dfdbd5e9df6d08ccf/images/esp_ai_thinker_camera.jpg -------------------------------------------------------------------------------- /images/headline.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neumi/esp32_camera_webstream/dd4c48adae8f36bfa32ee53dfdbd5e9df6d08ccf/images/headline.jpg -------------------------------------------------------------------------------- /python/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neumi/esp32_camera_webstream/dd4c48adae8f36bfa32ee53dfdbd5e9df6d08ccf/python/placeholder.jpg -------------------------------------------------------------------------------- /python/receive_stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import websockets 3 | import binascii 4 | from io import BytesIO 5 | 6 | from PIL import Image, UnidentifiedImageError 7 | 8 | def is_valid_image(image_bytes): 9 | try: 10 | Image.open(BytesIO(image_bytes)) 11 | # print("image OK") 12 | return True 13 | except UnidentifiedImageError: 14 | print("image invalid") 15 | return False 16 | 17 | async def handle_connection(websocket, path): 18 | while True: 19 | try: 20 | message = await websocket.recv() 21 | print(len(message)) 22 | if len(message) > 5000: 23 | if is_valid_image(message): 24 | #print(message) 25 | with open("image.jpg", "wb") as f: 26 | f.write(message) 27 | 28 | print() 29 | except websockets.exceptions.ConnectionClosed: 30 | break 31 | 32 | async def main(): 33 | server = await websockets.serve(handle_connection, '0.0.0.0', 3001) 34 | await server.wait_closed() 35 | 36 | asyncio.run(main()) 37 | -------------------------------------------------------------------------------- /python/send_image_stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import websockets 3 | import binascii 4 | from io import BytesIO 5 | from PIL import Image 6 | from flask import Flask, Response 7 | from base64 import b64encode 8 | 9 | 10 | app = Flask(__name__) 11 | 12 | @app.route('/') 13 | def index(): 14 | return Response(get_image(), mimetype='multipart/x-mixed-replace; boundary=frame') 15 | 16 | 17 | def get_image(): 18 | while True: 19 | try: 20 | with open("image.jpg", "rb") as f: 21 | image_bytes = f.read() 22 | image = Image.open(BytesIO(image_bytes)) 23 | img_io = BytesIO() 24 | image.save(img_io, 'JPEG') 25 | img_io.seek(0) 26 | img_bytes = img_io.read() 27 | yield (b'--frame\r\n' 28 | b'Content-Type: image/jpeg\r\n\r\n' + img_bytes + b'\r\n') 29 | 30 | except Exception as e: 31 | print("encountered an exception: ") 32 | print(e) 33 | 34 | with open("placeholder.jpg", "rb") as f: 35 | image_bytes = f.read() 36 | image = Image.open(BytesIO(image_bytes)) 37 | img_io = BytesIO() 38 | image.save(img_io, 'JPEG') 39 | img_io.seek(0) 40 | img_bytes = img_io.read() 41 | yield (b'--frame\r\n' 42 | b'Content-Type: image/jpeg\r\n\r\n' + img_bytes + b'\r\n') 43 | continue 44 | 45 | app.run(host='0.0.0.0', debug=False, threaded=True) 46 | --------------------------------------------------------------------------------