I took an ESP32 board, with a camera and LCD, and turned it into an autoguiding component for my spectroheliograph setup. Below’s the source code — before you judge it, think about it as an as-is release, half of it written while recording the Sun itself, mostly for fun. Some day in the future I may clean it up. PS: form9 from the printscreen got a decent title.
Note that, for this particular setup featured here:
- the ESP32 / OV2640 / ST7789 development board in the picture doesn’t have remaining free pins (maybe if the SPI/I2C buses are tapped into). My solution reports the center of gravity through the serial port.
- the EQ3 hand controller originally doesn’t have an ST4 connector
Both limitations from above have been overcome in my particular setup: I rebuilt the EQ3’s controller from the ground up, only the stepper motors are from factory, and it includes a special spectroheliograph scan mode. Both the autoguider and the otherwise standalone embedded device EQ3 controller connect to a desktop app, to the The Soapbox MountPusher Guider assembly. Thus the control is centralized there and Sharpcap, camera cooling, autoguiding etc are all orchestrated in a concert. See the family portrait below.
Some quick photos
// BOARD: "AI Thinker ESP32-Cam" is one that works // devkit1: failed ot get image #include "esp_camera.h" #include "soc/soc.h" #include "soc/rtc_cntl_reg.h" #include "driver/rtc_io.h" #include#define JPG_QUALITY 16 #define COG_PIXEL_STEP 4 #define USE_LENS_CORRECTION 1 // the original vignetting something #define APPLY_HORIZONTAL_FLIP 0 #define APPLY_VERTICAL_FLIP 0 #define COUNTED_PIXELS_FOR_HISTOGRAM_EXTREME_PERCENT 10 //docs: https://www.atomic14.com/2023/08/31/esp32-s3-adafruit-st7789-hardware-spi #define CAMERA_MODEL_WROVER_KIT // Has PSRAM //const framesize_t DEFINED_FRAMESIZE = FRAMESIZE_XGA; const framesize_t DEFINED_FRAMESIZE = FRAMESIZE_SXGA; #define asMegaHz(X) (1000UL * 1000UL * X) uint8_t scanLine[8192]; typedef struct TSumma { int where; uint64_t summa; } TSumma; typedef struct TcenterOfGravity { TSumma row; TSumma col; uint32_t updatedAtMillis; uint32_t index; int markedX; int markedY; int histogramWidth; } TcenterOfGravity; typedef struct TNormalizedLumin { uint16_t brightest; uint16_t faintest; uint16_t originalBrightest; uint16_t originalFaintest; uint16_t range; uint32_t luminosityCounters[256]; int countedBrightest; int countedFaintest; int countedRange; } TNormalizedLumin; TcenterOfGravity mycog; TNormalizedLumin n; #define MAGIC_LEVEL 3 #define OBSERVE_MAGIC_LEVEL 0 #include // Include Adafruit Hardware-specific library for ST7789 #define TFT_WIDTH_PX 240 #define TFT_HEIGHT_PX 240 // Define ST7789 display pin connection #define TFT_CS 12 #define TFT_RST -1 // Or set to -1 and connect to Arduino RESET pin #define TFT_DC 15 #define TFT_BK 2 #define TFT_MOSI 19 #define TFT_MISO 22 #define TFT_SCLK 21 Adafruit_ST7789 *tft = NULL; uint16_t tftBuffer[TFT_WIDTH_PX]; uint16_t IRAM_ATTR rgbToRGB565(uint8_t r, uint8_t g, uint8_t b){ uint16_t red = r >> 3; uint16_t blue = b >> 3; uint16_t green = g >> 2; uint16_t ret = (red << 6+5) | (green << 5) | blue; return ret; } void IRAM_ATTR printToTft(camera_fb_t * fb){ int step = (fb->height / TFT_WIDTH_PX); if (step < 1){ step = 1; } int lineX = mycog.markedX; int lineY = mycog.markedY; int destX = 0; int destY = 0; int lastPy = -1; for (int y=0; y height / TFT_HEIGHT_PX; int py_next = (y+1) * fb->height / TFT_HEIGHT_PX; if ((lineY >= py)&&(lineY < py_next)){ py = lineY; } if (py != lastPy){ memcpy(scanLine, fb->buf + (py*fb->width), fb->width); lastPy = py; } for (int x=0; x width / TFT_WIDTH_PX; int px_next = (x+1) * fb->width / TFT_WIDTH_PX; if ((lineX >= px)&&(lineX drawRGBBitmap(destX, destY, (uint16_t *)&tftBuffer, TFT_WIDTH_PX, 1); destY++; } } int lastCogIterations; #define OVERRIDE_PINS 1 #if OVERRIDE_PINS #define Y2_GPIO_NUM_OVER 34 #define Y3_GPIO_NUM_OVER 13 #define Y4_GPIO_NUM_OVER 26 #define Y5_GPIO_NUM_OVER 35 #define Y6_GPIO_NUM_OVER 39 #define Y7_GPIO_NUM_OVER 38 #define Y8_GPIO_NUM_OVER 37 #define Y9_GPIO_NUM_OVER 36 #define XCLK_GPIO_NUM_OVER 4 #define PCLK_GPIO_NUM_OVER 25 #define VSYNC_GPIO_NUM_OVER 5 #define HREF_GPIO_NUM_OVER 27 #define SIOD_GPIO_NUM_OVER 18 #define SIOC_GPIO_NUM_OVER 23 #define PWDN_GPIO_NUM_OVER -1 #define RESET_GPIO_NUM_OVER -1 #else #define Y2_GPIO_NUM_OVER Y2_GPIO_NUM #define Y3_GPIO_NUM_OVER Y3_GPIO_NUM #define Y4_GPIO_NUM_OVER Y4_GPIO_NUM #define Y5_GPIO_NUM_OVER Y5_GPIO_NUM #define Y6_GPIO_NUM_OVER Y6_GPIO_NUM #define Y7_GPIO_NUM_OVER Y7_GPIO_NUM #define Y8_GPIO_NUM_OVER Y8_GPIO_NUM #define Y9_GPIO_NUM_OVER Y9_GPIO_NUM #define XCLK_GPIO_NUM_OVER XCLK_GPIO_NUM #define PCLK_GPIO_NUM_OVER PCLK_GPIO_NUM #define VSYNC_GPIO_NUM_OVER VSYNC_GPIO_NUM #define HREF_GPIO_NUM_OVER HREF_GPIO_NUM #define SIOD_GPIO_NUM_OVER SIOD_GPIO_NUM #define SIOC_GPIO_NUM_OVER SIOC_GPIO_NUM #define PWDN_GPIO_NUM_OVER PWDN_GPIO_NUM #define RESET_GPIO_NUM_OVER RESET_GPIO_NUM #endif void tftSetup(){ pinMode(TFT_BK, OUTPUT); digitalWrite(TFT_BK, HIGH); int hardware_spi = 1; if (hardware_spi){ SPIClass *spi = new SPIClass(VSPI); spi->begin(TFT_SCLK, TFT_MISO, TFT_MOSI, TFT_CS); tft = new Adafruit_ST7789(spi, TFT_CS, TFT_DC, TFT_RST); // 80MHz should work, but you may need lower speeds tft->init(TFT_WIDTH_PX, TFT_HEIGHT_PX, SPI_MODE3); // Init ST7789 240x240 tft->setSPISpeed(asMegaHz(80)); }else{ tft = new Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST); tft->init(TFT_WIDTH_PX, TFT_HEIGHT_PX, SPI_MODE3); // Init ST7789 240x240 } } void IRAM_ATTR DebugPrint(const char *s){ return ; static uint32_t lastPrinted; uint32_t present = millis(); uint32_t age = present - lastPrinted; Serial.printf("\r\n%s [%d ms]\r\n", s, age); lastPrinted = present; }; TwoWire myI2C = TwoWire(1); void Camera_setConfig() { camera_config_t config; memset(&config, 0, sizeof(config)); int force_hardware_i2c = 1; int sda; int scl; if (force_hardware_i2c){ if (myI2C.begin(SIOD_GPIO_NUM_OVER, SIOC_GPIO_NUM_OVER, 400000)){ Serial.println("hardware i2c up"); }else{ Serial.println("hardware i2c down"); }; sda = -1; scl = -1; config.sccb_i2c_port = 1; }else{ sda = SIOD_GPIO_NUM_OVER; scl = SIOC_GPIO_NUM_OVER; } config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; config.pin_d0 = Y2_GPIO_NUM_OVER; config.pin_d1 = Y3_GPIO_NUM_OVER; config.pin_d2 = Y4_GPIO_NUM_OVER; config.pin_d3 = Y5_GPIO_NUM_OVER; config.pin_d4 = Y6_GPIO_NUM_OVER; config.pin_d5 = Y7_GPIO_NUM_OVER; config.pin_d6 = Y8_GPIO_NUM_OVER; config.pin_d7 = Y9_GPIO_NUM_OVER; config.pin_xclk = XCLK_GPIO_NUM_OVER; config.pin_pclk = PCLK_GPIO_NUM_OVER; config.pin_vsync = VSYNC_GPIO_NUM_OVER; config.pin_href = HREF_GPIO_NUM_OVER; config.pin_sscb_sda = sda; config.pin_sscb_scl = scl; config.pin_pwdn = PWDN_GPIO_NUM_OVER; config.pin_reset = RESET_GPIO_NUM_OVER; config.xclk_freq_hz = asMegaHz(20); config.pixel_format = PIXFORMAT_GRAYSCALE; config.frame_size = DEFINED_FRAMESIZE; //config.frame_size = FRAMESIZE_SVGA; //config.frame_size = FRAMESIZE_XGA; //config.frame_size = FRAMESIZE_QQVGA; config.jpeg_quality = JPG_QUALITY; config.fb_count = 3; // config.grab_mode = CAMERA_GRAB_LATEST; //config.fb_location = CAMERA_FB_IN_DRAM; esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { Serial.printf("Failed to init camera, error 0x%x", err); return; } sensor_t * cameraModule = esp_camera_sensor_get(); // (-2 to 2) cameraModule->set_brightness(cameraModule, 0); // (-2 to 2) cameraModule->set_contrast(cameraModule, 0); // (-2 to 2) cameraModule->set_saturation(cameraModule, 0); cameraModule->set_special_effect(cameraModule, 0); cameraModule->set_whitebal(cameraModule, 1); cameraModule->set_awb_gain(cameraModule, 1); cameraModule->set_wb_mode(cameraModule, 0); cameraModule->set_exposure_ctrl(cameraModule, 1); cameraModule->set_aec2(cameraModule, 0); cameraModule->set_ae_level(cameraModule, 0); // (0 to 1200) cameraModule->set_aec_value(cameraModule, 300); cameraModule->set_gain_ctrl(cameraModule, 1); // (0 to 30) cameraModule->set_agc_gain(cameraModule, 0); // (0 to 6)step cameraModule->set_gainceiling(cameraModule, (gainceiling_t)0); cameraModule->set_bpc(cameraModule, 0); cameraModule->set_wpc(cameraModule, 1); cameraModule->set_raw_gma(cameraModule, 1); cameraModule->set_lenc(cameraModule, USE_LENS_CORRECTION); cameraModule->set_hmirror(cameraModule, APPLY_HORIZONTAL_FLIP); cameraModule->set_vflip(cameraModule, APPLY_VERTICAL_FLIP); cameraModule->set_dcw(cameraModule, 1); cameraModule->set_colorbar(cameraModule, 0); } void printToSerialAsAsciiArt(camera_fb_t * fb){ int step = (fb->height / 50); if (step < 1){ step = 1; } int lineX = mycog.markedX; int lineY = mycog.markedY; int drawnX = 0; int drawnY = 0; for (int y=0; y height; y += step){ for (int x=0; x width; x += step){ int py = y; int px = x; if ((abs(y - lineY) <= step)&&(0 == drawnY)){ py = lineY; //drawnY = 1; } if ((abs(x - lineX) <= step)&&(0 == drawnX)){ px = lineX; //drawnX = 1; } uint8_t b = fb->buf[py*fb->width + px]; char levels[] = { '@', 'B', 'h', ':', '.', ' ', 0}; int i = b / (256 / strlen(levels)); if (i == strlen(levels)){ i--; } char n[2] = { levels[i], 0 }; if (OBSERVE_MAGIC_LEVEL){ if (MAGIC_LEVEL == b){ n[0] = '#'; } } char m[16]; strcpy(m, n); // double it, for the serial monitor's character width/height strcat(m, n); Serial.print(m); } Serial.println(); } } void IRAM_ATTR NormalizeLuminosities_init(camera_fb_t * fb, TNormalizedLumin *n, uint8_t *actualFrameBuf){ n->brightest = 0; n->faintest = 255; memset(n->luminosityCounters, 0, sizeof(n->luminosityCounters)); uint32_t brightestLum = 50; uint32_t faintestLum = 256; size_t step = COG_PIXEL_STEP; uint32_t linesFittingIntoTheScanlineBuffer = 1; //sizeof(scanLine) / fb->width; uint32_t linesInTheFramebuffer = 0; uint32_t linesOffset = 0; for (size_t y=0; y < fb->height; y+= step){ if (0 == linesInTheFramebuffer){ memcpy(scanLine, actualFrameBuf + (y*fb->width), fb->width * linesFittingIntoTheScanlineBuffer); linesInTheFramebuffer = linesFittingIntoTheScanlineBuffer; linesOffset = 0; } for (size_t x=0; x < fb->width; x+= step){ uint8_t lum = scanLine[linesOffset*fb->width + x]; n->luminosityCounters[lum]++; if (lum > brightestLum){ brightestLum = lum; } if (lum < faintestLum){ faintestLum = lum; } } linesOffset++; linesInTheFramebuffer--; }; n->originalBrightest = brightestLum; n->originalFaintest = faintestLum; // linear stretch int range = (brightestLum - faintestLum); if (0 == range){ range = 1; } n->range = range; /* for (size_t y=0; y < fb->height; y++){ memcpy(scanLine, actualFrameBuf + (y*fb->width), fb->width); int changes = 0; for (size_t x=0; x < fb->width; x++){ int lum = scanLine[x]; lum -= faintestLum; int f = 256 / range; lum *= f; if (lum > 255){ lum = 255; } if (lum != scanLine[x]){ scanLine[x] = lum; changes++; } } if (changes > 0){ memcpy(actualFrameBuf + (y*fb->width), scanLine, fb->width); } }; */ n->brightest = 255; n->faintest = 0; n->countedFaintest = 0; n->countedBrightest = 0; int32_t pixelCount = (fb->width * fb->height) / (COG_PIXEL_STEP*COG_PIXEL_STEP); int32_t expectedPixelCount = (pixelCount/100)*COUNTED_PIXELS_FOR_HISTOGRAM_EXTREME_PERCENT; int32_t faintSumma = 0; for (int i=0; i<256; i++){ if (faintSumma > expectedPixelCount){ if (0 == n->countedFaintest){ n->countedFaintest = i; } } faintSumma += n->luminosityCounters[i]; } int32_t brightSumma = 0; for (int i=255; i>16; i--){ if (brightSumma > expectedPixelCount){ if (0 == n->countedBrightest){ n->countedBrightest = i; } } brightSumma += n->luminosityCounters[i]; } n->countedRange = n->countedBrightest - n->countedFaintest; if (n->countedRange < 1){ n->countedRange = 1; } } uint8_t IRAM_ATTR NormalizeLuminosities_getScaledLuminance(uint8_t originalLum, TNormalizedLumin *n){ int lum = originalLum; lum -= n->originalFaintest; int f = 256 / n->range; lum *= f; if (lum > 255){ lum = 255; } return (uint8_t) lum; } void IRAM_ATTR findCenterOfMass(camera_fb_t * fb, TcenterOfGravity *cog, uint8_t *actualFrameBuf){ static uint32_t lastEvalDone = 0; if (millis() - lastEvalDone < 500){ return ; } size_t step = COG_PIXEL_STEP; size_t x_start; size_t y_start; size_t x_end; size_t y_end; //take a square if (fb->width > fb->height){ y_start = 0; y_end = fb->height; x_start = (fb->width - fb->height) / 2; x_end = x_start + y_end; }else{ x_start = 0; x_end = fb->width; y_start = (fb->height - fb->width) / 2; y_end = y_start + x_end; } NormalizeLuminosities_init(fb, &n, actualFrameBuf); cog->histogramWidth = n.countedRange; DebugPrint("normalize init done"); uint32_t brightestLum = n.brightest; int32_t moment_x = 0; int32_t moment_y = 0; int32_t count = 0; int retries = 5; lastCogIterations = 0; while (0 == count){ lastCogIterations++; brightestLum *= 8; brightestLum /= 10; for (size_t y=y_start; y < y_end; y += step){ memcpy(scanLine, actualFrameBuf + (y*fb->width), x_end); int changes = 0; int firstChange = -1; int lastChange = 0; for (size_t x=x_start; x < x_end; x += step){ uint8_t lum = NormalizeLuminosities_getScaledLuminance(scanLine[x], &n); if (lum >= brightestLum){ count++; moment_x += (x - x_start); moment_y += (y - y_start); } if (OBSERVE_MAGIC_LEVEL){ if (MAGIC_LEVEL == scanLine[x]){ scanLine[x]++; changes++; if (-1 == firstChange){ firstChange = x; } lastChange = x; } } } if (changes > 0){ //prevent the magic level from showing up size_t changeSize = lastChange - firstChange + 1; memcpy(actualFrameBuf + (y*fb->width)+firstChange, scanLine+firstChange, changeSize); } }; if (0 == retries){ break; } retries--; } DebugPrint("cog scan done"); if (count > 0){ cog->col.where = (moment_x / count) - ((x_end - x_start) / 2); cog->row.where = (moment_y / count) - ((y_end - y_start) / 2); cog->updatedAtMillis = millis(); cog->index++; // mark it with a cross cog->markedY = (cog->row.where + fb->height /2); cog->markedX = (cog->col.where + fb->width/2); if (OBSERVE_MAGIC_LEVEL){ memcpy(scanLine, actualFrameBuf + (cog->markedY*fb->width), fb->width); for (int x=0; x width; x++){ scanLine[x] = MAGIC_LEVEL; } memcpy(actualFrameBuf + (cog->markedY*fb->width), scanLine, fb->width); for (int y=0; y height; y++){ actualFrameBuf[y *fb->width + cog->markedX] = MAGIC_LEVEL; } DebugPrint("cog cross drawn"); } } lastEvalDone = millis(); } void printOut(camera_fb_t * fb){ static uint32_t lastPrint = 0; if (millis() - lastPrint > 1000){ //printToSerialAsAsciiArt(fb); DebugPrint("ascii art done"); printToTft(fb); DebugPrint("tft done"); lastPrint = millis(); } }; void takeNewPhoto(void) { DebugPrint("getting frame"); camera_fb_t * fb = esp_camera_fb_get(); if (!fb) { Serial.println("failed to get image frame"); delay(1000); return; } DebugPrint("frame gotten"); Serial.print("f"); Serial.printf("we have a photo %d %d %d\r\n", fb->len, fb->width, fb->height); // find center of gravity findCenterOfMass(fb, &mycog, fb->buf); Serial.printf("##mass[%d]: %d %d %d\r\n", mycog.index, mycog.col.where, mycog.row.where, mycog.histogramWidth); char lci[32]; strcpy(lci, "cog found "); itoa(lastCogIterations, lci+strlen(lci), 10); DebugPrint(lci); printOut(fb); DebugPrint("printout done"); esp_camera_fb_return(fb); DebugPrint("framevuffer returned"); delay(100); } void setup() { memset(&mycog, 0, sizeof(mycog)); Serial.begin(115200); tftSetup(); Serial.print("init camera..."); Camera_setConfig(); Serial.println("Camera OK!"); } void loop() { takeNewPhoto(); }
That is it, the source code is rather trivial.








