Add features: Brightness control, inverse colors, Dithering effect, and ANSII colors support.

This commit is contained in:
Harshit-Dhanwalkar 2026-05-19 17:28:19 +05:30
parent 7b3b2cfd0b
commit 1c9834483a
6 changed files with 432 additions and 134 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# Build dir
build/
# Prerequisites
*.d

View file

@ -1,15 +1,16 @@
CC = gcc
CFLAGS = -Wall -Wextra -O2 -Iinclude
LDFLAGS = -lm
# LDFLAGS += -lpthread
SRCDIR = src
INCDIR = include
OBJDIR = obj
BINDIR = .
BUILDDIR = build
OBJDIR = $(BUILDDIR)/obj
SOURCES = $(wildcard $(SRCDIR)/*.c)
OBJECTS = $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(SOURCES))
TARGET = $(BINDIR)/webcam_ascii
TARGET = $(BUILDDIR)/webcam_ascii
.PHONY: all clean
@ -25,4 +26,4 @@ $(OBJDIR):
mkdir -p $(OBJDIR)
clean:
rm -rf $(OBJDIR) $(TARGET)
rm -rf $(BUILDDIR)

View file

@ -1,15 +1,30 @@
#ifndef ASCII_H
#define ASCII_H
#include <stddef.h>
#include <stdint.h>
#define ASCII_CHARS " .:-=+*#%@"
#define ASCII_CHARS_DEFAULT " .:-=+*#%@"
typedef struct {
int brightness; /* additive offset applied to gray: -128..128 */
int contrast; /* multiplier in percent; 100 = no change */
int invert; /* non-zero: flip brightness -> charset mapping */
int color; /* non-zero: emit ANSI truecolor escape codes */
int dither; /* non-zero: apply Floyd-Steinberg dithering */
const char *charset; /* custom charset string; NULL -> ASCII_CHARS_DEFAULT */
} ascii_opts_t;
// Convert YUYV raw data to grayscale
void yuyv_to_gray(const uint8_t *yuyv, uint8_t *gray, int width, int height);
void yuyv_to_rgb(const uint8_t *yuyv, uint8_t *rgb, int width, int height);
// Output buffer sizing
size_t ascii_out_size(int dst_w, int dst_h, int color);
// grayscale to ASCII output grid
char *grayscale_to_ascii(const uint8_t *gray, int src_w, int src_h,
int dst_w, int dst_h);
int grayscale_to_ascii(const uint8_t *gray, const uint8_t *rgb, int src_w,
int src_h, int dst_w, int dst_h, char *out,
size_t out_size, const ascii_opts_t *opts);
#endif

View file

@ -1,49 +1,223 @@
#include "ascii.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CHECK(x, msg) if ((x) < 0) { perror(msg); goto cleanup; }
typedef struct {
uint8_t *raw_frame;
uint8_t *gray;
char *ascii;
} frame_t;
char *grayscale_to_ascii(const uint8_t *gray, int src_w, int src_h,
int dst_w, int dst_h) {
// Allocate output string: each row has dst_w chars + newline, plus null terminator
char *output = malloc(dst_w * dst_h + dst_h + 1);
if (!output) return NULL;
double block_w = (double)src_w / dst_w;
double block_h = (double)src_h / dst_h;
const char *ascii = ASCII_CHARS;
int ascii_len = strlen(ascii);
int out_idx = 0;
for (int y = 0; y < dst_h; y++) {
for (int x = 0; x < dst_w; x++) {
long total = 0;
int count = 0;
int y_start = (int)(y * block_h);
int y_end = (int)((y + 1) * block_h);
int x_start = (int)(x * block_w);
int x_end = (int)((x + 1) * block_w);
for (int sy = y_start; sy < y_end && sy < src_h; sy++) {
for (int sx = x_start; sx < x_end && sx < src_w; sx++) {
total += gray[sy * src_w + sx];
count++;
}
}
unsigned char avg = (count > 0) ? (total / count) : 0;
int idx = avg * (ascii_len - 1) / 255;
output[out_idx++] = ascii[idx];
}
output[out_idx++] = '\n';
}
output[out_idx] = '\0';
return output;
// Helpers
static inline uint8_t clamp_u8(int v) {
return (v < 0) ? 0 : (v > 255) ? 255 : (uint8_t)v;
}
// Image conversion
void yuyv_to_gray(const uint8_t *yuyv, uint8_t *gray, int width, int height) {
int n = width * height;
for (int i = 0; i < n; i++)
gray[i] = yuyv[i * 2]; // Y sample at even bytes in YUYV format
}
void yuyv_to_rgb(const uint8_t *yuyv, uint8_t *rgb, int width, int height) {
int pairs = (width * height) / 2;
for (int i = 0; i < pairs; i++) {
int y0 = yuyv[i * 4 + 0];
int u = yuyv[i * 4 + 1];
int y1 = yuyv[i * 4 + 2];
int v = yuyv[i * 4 + 3];
int d = u - 128;
int e = v - 128;
for (int p = 0; p < 2; p++) {
int c = ((p == 0) ? y0 : y1) - 16;
uint8_t *px = rgb + (i * 2 + p) * 3;
px[0] = clamp_u8((298 * c + 409 * e + 128) >> 8); // R
px[1] = clamp_u8((298 * c - 100 * d - 208 * e + 128) >> 8); // G
px[2] = clamp_u8((298 * c + 516 * d + 128) >> 8); // B
}
}
}
// Buffer sizing
size_t ascii_out_size(int dst_w, int dst_h, int color) {
/* prefix: 3 bytes + 1 Char */
if (color) {
/* Per cell: "\033[38;2;255;255;255m" (20) + char (1) = 21
* Per row end: "\033[0m\n" (6) */
return 3 + (size_t)dst_h * ((size_t)dst_w * 21 + 6) + 1;
} else {
/* Per cell: 1 byte; per row: + newline */
return 3 + (size_t)dst_h * ((size_t)dst_w + 1) + 1;
}
}
// Grayscale to ascii
int grayscale_to_ascii(const uint8_t *gray, const uint8_t *rgb, int src_w,
int src_h, int dst_w, int dst_h, char *out,
size_t out_size, const ascii_opts_t *opts) {
const char *charset =
(opts && opts->charset) ? opts->charset : ASCII_CHARS_DEFAULT;
int nchars = (int)strlen(charset);
int brightness = opts ? opts->brightness : 0;
int contrast = opts ? opts->contrast : 100;
int invert = opts ? opts->invert : 0;
int do_color = opts && opts->color && (rgb != NULL);
int do_dither = opts ? opts->dither : 0;
// Blocking Widht and height pixels in source pixels
double bw = (double)src_w / dst_w;
double bh = (double)src_h / dst_h;
// Downsample to (dst_w x dst_h)
uint8_t *small_g = malloc(dst_w * dst_h);
uint8_t *small_rgb = do_color ? malloc(dst_w * dst_h * 3) : NULL;
if (!small_g || (do_color && !small_rgb)) {
free(small_g);
free(small_rgb);
return -1;
}
for (int y = 0; y < dst_h; y++) {
int ys = (int)(y * bh);
int ye = (int)((y + 1) * bh);
if (ye <= ys)
ye = ys + 1;
for (int x = 0; x < dst_w; x++) {
int xs = (int)(x * bw);
int xe = (int)((x + 1) * bw);
if (xe <= xs)
xe = xs + 1;
long tg = 0, tr = 0, tgv = 0, tb = 0;
int cnt = 0;
for (int sy = ys; sy < ye && sy < src_h; sy++) {
for (int sx = xs; sx < xe && sx < src_w; sx++) {
tg += gray[sy * src_w + sx];
if (do_color) {
const uint8_t *px = rgb + (sy * src_w + sx) * 3;
tr += px[0];
tgv += px[1];
tb += px[2];
}
cnt++;
}
}
if (cnt == 0)
cnt = 1;
// Brightness and contrast
int gv = (int)(tg / cnt);
if (contrast != 100)
gv = 128 + (gv - 128) * contrast / 100;
gv += brightness;
small_g[y * dst_w + x] = clamp_u8(gv);
if (do_color) {
uint8_t *out_px = small_rgb + (y * dst_w + x) * 3;
out_px[0] = clamp_u8((int)(tr / cnt));
out_px[1] = clamp_u8((int)(tgv / cnt));
out_px[2] = clamp_u8((int)(tb / cnt));
}
}
}
// Floyd-Steinberg dithering
if (do_dither) {
int16_t *eb = malloc(dst_w * dst_h * sizeof(int16_t));
if (eb) {
for (int i = 0; i < dst_w * dst_h; i++)
eb[i] = (int16_t)small_g[i];
for (int y = 0; y < dst_h; y++) {
for (int x = 0; x < dst_w; x++) {
int old_v = eb[y * dst_w + x];
// Quantise to nearest charset level
int qi = old_v * (nchars - 1) / 255;
if (qi < 0)
qi = 0;
if (qi >= nchars)
qi = nchars - 1;
int new_v = qi * 255 / (nchars - 1);
eb[y * dst_w + x] = (int16_t)new_v;
int err = old_v - new_v;
#define FS_ADD(DY, DX, NUM) \
do { \
int ny = y + (DY), nx = x + (DX); \
if (nx >= 0 && nx < dst_w && ny >= 0 && ny < dst_h) \
eb[ny * dst_w + nx] += (int16_t)(err * (NUM) / 16); \
} while (0)
FS_ADD(0, 1, 7);
FS_ADD(1, -1, 3);
FS_ADD(1, 0, 5);
FS_ADD(1, 1, 1);
#undef FS_ADD
}
}
for (int i = 0; i < dst_w * dst_h; i++)
small_g[i] = clamp_u8(eb[i]);
free(eb);
}
/* HACK: If malloc fails silently fall back to no dithering */
}
// Render into caller's buffer
int out_idx = 0;
// Cursor repositions without erasing
static const char HOME[] = "\033[H";
if (out_size > sizeof(HOME)) {
memcpy(out, HOME, sizeof(HOME) - 1);
out_idx = sizeof(HOME) - 1;
}
for (int y = 0; y < dst_h; y++) {
for (int x = 0; x < dst_w; x++) {
int gv = small_g[y * dst_w + x];
int idx = gv * (nchars - 1) / 255;
if (idx < 0)
idx = 0;
if (idx >= nchars)
idx = nchars - 1;
if (invert)
idx = nchars - 1 - idx;
char ch = charset[idx];
if (do_color) {
const uint8_t *px = small_rgb + (y * dst_w + x) * 3;
int w = snprintf(out + out_idx, out_size - (size_t)out_idx,
"\033[38;2;%d;%d;%dm%c", px[0], px[1], px[2], ch);
if (w > 0 && (size_t)(out_idx + w) < out_size)
out_idx += w;
} else {
if ((size_t)(out_idx + 1) < out_size)
out[out_idx++] = ch;
}
}
if (do_color) {
int w = snprintf(out + out_idx, out_size - (size_t)out_idx, "\033[0m\n");
if (w > 0)
out_idx += w;
} else {
if ((size_t)(out_idx + 1) < out_size)
out[out_idx++] = '\n';
}
}
if ((size_t)out_idx < out_size)
out[out_idx] = '\0';
else
out[out_size - 1] = '\0';
free(small_g);
free(small_rgb);
return out_idx;
}

View file

@ -1,6 +1,7 @@
#include "capture.h"
#include "ascii.h"
#include "capture.h"
#include "timing.h"
#include <getopt.h>
#include <signal.h>
#include <stdio.h>
@ -10,101 +11,204 @@
#include <unistd.h>
// Defaults
#define DEFAULT_ASCII_WIDTH 80
#define DEFAULT_ASCII_WIDTH 80
#define DEFAULT_ASCII_HEIGHT 40
#define DEFAULT_CAPTURE_WIDTH 160
#define DEFAULT_CAPTURE_WIDTH 160
#define DEFAULT_CAPTURE_HEIGHT 120
#define DEFAULT_FPS 20
// Signal handling
volatile sig_atomic_t keep_running = 1;
void handle_signal(int sig) { (void)sig; keep_running = 0; }
void print_usage(char *prog) {
fprintf(stderr,
"Usage: %s [-d <device>] [-W <width>] [-H <height>] [-f <fps>]\n",
prog);
fprintf(stderr, " -d <device> : video device (default: /dev/video0)\n");
fprintf(stderr, " -W <width> : ASCII output width (default: %d)\n", DEFAULT_ASCII_WIDTH);
fprintf(stderr, " -H <height> : ASCII output height (default: %d)\n", DEFAULT_ASCII_HEIGHT);
fprintf(stderr, " -f <fps> : target framerate (default: %d)\n", DEFAULT_FPS);
void handle_signal(int sig) {
(void)sig;
keep_running = 0;
}
// Usage
static void print_usage(const char *prog) {
fprintf(
stderr,
"Usage: %s [options]\n"
"\n"
"Capture options:\n"
" -d <device> video device (default: /dev/video0)\n"
" -w <width> capture width (default: %d)\n"
" -h <height> capture height (default: %d)\n"
" -f <fps> target framerate (default: %d)\n"
"\n"
"Output options:\n"
" -W <width> ASCII output columns (default: %d)\n"
" -H <height> ASCII output rows (default: %d)\n"
" -s <chars> custom charset string (default: \"%s\")\n"
"\n"
"Image adjustments:\n"
" -b <val> brightness offset -128..128 (default: 0)\n"
" -c <val> contrast in percent >0; 100=none (default: 100)\n"
" -i invert brightness->charset mapping\n"
" -C colour output (ANSI truecolor)\n"
" -D Floyd-Steinberg dithering\n",
prog, DEFAULT_CAPTURE_WIDTH, DEFAULT_CAPTURE_HEIGHT, DEFAULT_FPS,
DEFAULT_ASCII_WIDTH, DEFAULT_ASCII_HEIGHT, ASCII_CHARS_DEFAULT);
}
// Main
int main(int argc, char *argv[]) {
signal(SIGINT, handle_signal);
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
// Config
char *device = "/dev/video0";
int ascii_w = DEFAULT_ASCII_WIDTH;
int ascii_h = DEFAULT_ASCII_HEIGHT;
int fps = DEFAULT_FPS;
// Config
char *device = "/dev/video0";
int ascii_w = DEFAULT_ASCII_WIDTH;
int ascii_h = DEFAULT_ASCII_HEIGHT;
int cap_w = DEFAULT_CAPTURE_WIDTH;
int cap_h = DEFAULT_CAPTURE_HEIGHT;
int fps = DEFAULT_FPS;
int opt;
while ((opt = getopt(argc, argv, "d:W:H:f:")) != -1) {
switch (opt) {
case 'd': device = optarg; break;
case 'W': ascii_w = atoi(optarg); if (ascii_w <= 0) ascii_w = DEFAULT_ASCII_WIDTH; break;
case 'H': ascii_h = atoi(optarg); if (ascii_h <= 0) ascii_h = DEFAULT_ASCII_HEIGHT; break;
case 'f': fps = atoi(optarg); if (fps <= 0) fps = DEFAULT_FPS; break;
default: print_usage(argv[0]); return 1;
}
ascii_opts_t opts = {
.brightness = 0,
.contrast = 100,
.invert = 0,
.color = 0,
.dither = 0,
.charset = NULL,
};
// CLI parsing
int opt;
while ((opt = getopt(argc, argv, "d:W:H:w:h:f:b:c:iCDs:")) != -1) {
switch (opt) {
case 'd':
device = optarg;
break;
case 'W':
ascii_w = atoi(optarg);
if (ascii_w <= 0)
ascii_w = DEFAULT_ASCII_WIDTH;
break;
case 'H':
ascii_h = atoi(optarg);
if (ascii_h <= 0)
ascii_h = DEFAULT_ASCII_HEIGHT;
break;
case 'w':
cap_w = atoi(optarg);
if (cap_w <= 0)
cap_w = DEFAULT_CAPTURE_WIDTH;
break;
case 'h':
cap_h = atoi(optarg);
if (cap_h <= 0)
cap_h = DEFAULT_CAPTURE_HEIGHT;
break;
case 'f':
fps = atoi(optarg);
if (fps <= 0)
fps = DEFAULT_FPS;
break;
case 'b':
opts.brightness = atoi(optarg);
break;
case 'c':
opts.contrast = atoi(optarg);
if (opts.contrast <= 0)
opts.contrast = 100;
break;
case 'i':
opts.invert = 1;
break;
case 'C':
opts.color = 1;
break;
case 'D':
opts.dither = 1;
break;
case 's':
opts.charset = optarg;
break;
default:
print_usage(argv[0]);
return 1;
}
}
// Capture resolution (fixed for simplicity, could also be made configurable)
int cap_w = DEFAULT_CAPTURE_WIDTH;
int cap_h = DEFAULT_CAPTURE_HEIGHT;
timing_init(fps);
timing_init(fps);
// Open webcam
webcam_t cam = {.fd = -1, .buffer = MAP_FAILED};
if (webcam_init(&cam, device, cap_w, cap_h) < 0) {
perror("webcam_init");
return 1;
}
fprintf(stderr, "Device: %s | capture %dx%d | ASCII %dx%d | %d fps%s%s%s\n",
device, cam.width, cam.height, ascii_w, ascii_h, fps,
opts.color ? " | color" : "", opts.dither ? " | dither" : "",
opts.invert ? " | inverted" : "");
webcam_t cam = { .fd = -1, .buffer = MAP_FAILED };
if (webcam_init(&cam, device, cap_w, cap_h) < 0) {
perror("webcam_init");
return 1;
}
printf("Webcam opened: %s, capture resolution %dx%d\n", device, cam.width, cam.height);
// Allocate pixel buffers
int cam_pixels = cam.width * cam.height;
uint8_t *gray = malloc(cam.width * cam.height);
if (!gray) {
perror("malloc gray");
webcam_cleanup(&cam);
return 1;
}
uint8_t *gray = malloc(cam_pixels);
uint8_t *rgb = opts.color ? malloc(cam_pixels * 3) : NULL;
printf("Starting ASCII stream (%dx%d), target %d fps. Press Ctrl+C to stop.\n",
ascii_w, ascii_h, fps);
struct timespec start_time;
while (keep_running) {
clock_gettime(CLOCK_MONOTONIC, &start_time);
if (webcam_wait_frame(&cam, 1000) < 0) {
// timeout or error, just continue
continue;
}
if (webcam_capture_frame(&cam, gray) < 0) {
perror("capture_frame");
break;
}
char *ascii_art = grayscale_to_ascii(gray, cam.width, cam.height,
ascii_w, ascii_h);
if (ascii_art) {
printf("\033[2J\033[H"); // clear screen, home cursor
fputs(ascii_art, stdout);
fflush(stdout);
free(ascii_art);
}
if (webcam_requeue_buffer(&cam) < 0) {
perror("requeue_buffer");
break;
}
timing_sleep(&start_time);
}
printf("\nStopping...\n");
if (!gray || (opts.color && !rgb)) {
perror("malloc pixel buffers");
free(gray);
webcam_cleanup(&cam);
return 0;
return 1;
}
// Allocate output string buffer
size_t out_size = ascii_out_size(ascii_w, ascii_h, opts.color);
char *out_buf = malloc(out_size);
if (!out_buf) {
perror("malloc out_buf");
free(gray);
free(rgb);
webcam_cleanup(&cam);
return 1;
}
// Initial full clear
write(STDOUT_FILENO, "\033[2J\033[H", 7);
// Main loop
struct timespec frame_start;
while (keep_running) {
clock_gettime(CLOCK_MONOTONIC, &frame_start);
if (webcam_wait_frame(&cam, 1000) < 0)
continue; // timeout, retry
if (webcam_capture_frame(&cam, gray) < 0) {
perror("capture_frame");
break;
}
if (opts.color && rgb)
yuyv_to_rgb((const uint8_t *)cam.buffer, rgb, cam.width, cam.height);
int len = grayscale_to_ascii(gray, rgb, cam.width, cam.height, ascii_w,
ascii_h, out_buf, out_size, &opts);
if (len > 0) {
write(STDOUT_FILENO, out_buf, (size_t)len);
}
if (webcam_requeue_buffer(&cam) < 0) {
perror("requeue_buffer");
break;
}
timing_sleep(&frame_start);
}
// Cleanup
write(STDOUT_FILENO, "\033[0m\033[?25h\n", 10);
fprintf(stderr, "Stopped.\n");
free(gray);
free(rgb);
free(out_buf);
webcam_cleanup(&cam);
return 0;
}

View file

@ -6,10 +6,11 @@ Ascii video output from your webcam in your terminal.
## TODO
- [x] Adjust width and height of capturing frame.
- [ ] Brightness/contrast adjustment.
- [ ] Custom ASCII charset via config file
- [ ] Reverse video - Invert brightness $\rightarrow$ charset mapping
- [ ] Color output - Extract U/V channels, map to ANSI/RGB codes
- [x] Brightness/contrast adjustment.
- [x] Reverse video - Invert brightness $\rightarrow$ charset mapping
- [x] Color output - Extract U/V channels, map to ANSI/RGB codes
- [ ] Add feature to record and save it in popular video formats like `.mp4`, `.mov` and `.gif`.
- [ ] Dithering
- [x] Dithering effect.
- [ ] A producer/consumer split with pthread_mutex + pthread_cond and a double-buffer swap would decouple them: one thread talks exclusively to V4L2, the other does ASCII conversion and writes.
- [ ] Migrate from C to Cpp.