"""Image Generation — local, NumPy-based procedural image generation.
No external dependencies. Generates images using:
- Procedural patterns (gradients, noise, fractals)
- Text-to-image via prompt interpretation (maps keywords to visual properties)
- SVG generation for vector graphics
- ASCII art generation from text prompts
All generation is 100% local — no API calls to DALL-E, Stable Diffusion, etc.
For production-quality image generation, connect an external service via
the connectors module.
"""
from __future__ import annotations
import base64
import hashlib
import io
import logging
import math
import os
import struct
from typing import Any
import numpy as np
logger = logging.getLogger(__name__)
class ImageGenerator:
"""Local image generator using NumPy.
Generates images from text prompts using procedural techniques:
- Color palette extraction from prompt keywords
- Pattern selection based on prompt themes
- Composition using gradients, noise, geometric shapes
- Output as BMP (no external deps) or SVG (vector)
Prompt interpretation:
- "sunset" → warm orange/pink gradient
- "ocean" → blue gradient with wave pattern
- "forest" → green gradient with noise texture
- "fire" → red/orange with flicker pattern
- "abstract" → random colorful shapes
- "geometric" → structured geometric patterns
"""
PROMPT_PALETTES = {
"sunset": [(255, 140, 50), (255, 80, 120), (100, 50, 150)],
"sunrise": [(255, 180, 80), (255, 120, 100), (150, 100, 200)],
"ocean": [(20, 80, 180), (40, 120, 200), (80, 180, 220)],
"sea": [(20, 80, 180), (40, 120, 200), (80, 180, 220)],
"water": [(40, 100, 180), (60, 140, 200), (100, 180, 220)],
"forest": [(20, 80, 30), (40, 120, 50), (80, 160, 70)],
"tree": [(20, 80, 30), (60, 100, 40), (100, 70, 40)],
"fire": [(255, 50, 0), (255, 120, 0), (255, 200, 50)],
"flame": [(255, 50, 0), (255, 120, 0), (255, 200, 50)],
"ice": [(180, 220, 255), (200, 240, 255), (220, 250, 255)],
"snow": [(200, 220, 240), (220, 240, 250), (240, 250, 255)],
"night": [(10, 10, 40), (20, 20, 60), (40, 40, 80)],
"space": [(0, 0, 20), (20, 10, 40), (60, 40, 100)],
"star": [(0, 0, 20), (40, 40, 80), (255, 255, 200)],
"desert": [(200, 170, 100), (220, 190, 130), (240, 210, 160)],
"sky": [(100, 150, 220), (130, 180, 240), (180, 210, 250)],
"grass": [(40, 120, 30), (60, 160, 40), (100, 200, 60)],
"mountain": [(80, 70, 60), (120, 110, 100), (160, 150, 140)],
"abstract": [(255, 50, 100), (50, 200, 255), (255, 200, 50)],
"geometric": [(50, 50, 150), (150, 50, 100), (50, 150, 200)],
"rainbow": [(255, 0, 0), (255, 128, 0), (255, 255, 0), (0, 255, 0), (0, 128, 255), (128, 0, 255)],
"metal": [(100, 100, 110), (140, 140, 150), (180, 180, 190)],
"gold": [(180, 140, 40), (220, 180, 60), (255, 220, 100)],
"neon": [(255, 0, 255), (0, 255, 255), (255, 255, 0)],
"dark": [(10, 10, 15), (20, 20, 30), (40, 40, 50)],
"light": [(240, 240, 250), (220, 220, 240), (200, 200, 230)],
}
PATTERN_TYPES = ["gradient", "radial", "noise", "fractal", "geometric", "waves"]
def __init__(self, default_size: tuple[int, int] = (256, 256)) -> None:
self.default_size = default_size
self._stats = {
"images_generated": 0,
"total_pixels_generated": 0,
"avg_generation_time_s": 0.0,
}
def generate(self, prompt: str, width: int = 0, height: int = 0,
pattern: str = "") -> dict[str, Any]:
"""Generate an image from a text prompt.
Args:
prompt: text description of the image
width: image width (0 = default)
height: image height (0 = default)
pattern: force a specific pattern type
Returns:
dict with image data (BMP bytes, base64), metadata
"""
import time
t0 = time.time()
w, h = (width, height) if width and height else self.default_size
palette = self._extract_palette(prompt)
pat = pattern or self._select_pattern(prompt)
# Generate pixel array
pixels = self._render(pat, w, h, palette)
# Encode as BMP
bmp_bytes = self._encode_bmp(pixels)
# Encode as base64 for embedding in HTML
b64 = base64.b64encode(bmp_bytes).decode()
elapsed = time.time() - t0
self._stats["images_generated"] += 1
self._stats["total_pixels_generated"] += w * h
self._stats["avg_generation_time_s"] = (
(self._stats["avg_generation_time_s"] * (self._stats["images_generated"] - 1) + elapsed)
/ self._stats["images_generated"]
)
return {
"prompt": prompt,
"width": w,
"height": h,
"pattern": pat,
"palette": palette,
"format": "bmp",
"size_bytes": len(bmp_bytes),
"base64": b64,
"elapsed_s": round(elapsed, 4),
}
def generate_svg(self, prompt: str, width: int = 256, height: int = 256) -> str:
"""Generate an SVG image from a prompt."""
palette = self._extract_palette(prompt)
pat = self._select_pattern(prompt)
svg_parts = [
f'')
return "\n".join(svg_parts)
def generate_ascii(self, prompt: str, width: int = 60, height: int = 20) -> str:
"""Generate ASCII art from a prompt."""
palette = self._extract_palette(prompt)
chars = " .:-=+*#%@"
pixels = self._render("noise", width, height, palette)
lines: list[str] = []
for y in range(height):
line = ""
for x in range(width):
brightness = int(np.mean(pixels[y, x])) // 26
line += chars[min(brightness, len(chars) - 1)]
lines.append(line)
return "\n".join(lines)
def _extract_palette(self, prompt: str) -> list[tuple[int, int, int]]:
"""Extract color palette from prompt keywords."""
prompt_lower = prompt.lower()
for keyword, palette in self.PROMPT_PALETTES.items():
if keyword in prompt_lower:
return palette
# Default: blue-purple gradient
return [(30, 30, 80), (60, 50, 120), (100, 80, 160)]
def _select_pattern(self, prompt: str) -> str:
"""Select a pattern type based on prompt keywords."""
prompt_lower = prompt.lower()
if any(kw in prompt_lower for kw in ["gradient", "sky", "sunset", "sunrise", "dawn", "dusk"]):
return "gradient"
if any(kw in prompt_lower for kw in ["radial", "burst", "explosion", "sun", "star"]):
return "radial"
if any(kw in prompt_lower for kw in ["noise", "texture", "rough", "chaos", "random"]):
return "noise"
if any(kw in prompt_lower for kw in ["fractal", "recursive", "mandelbrot", "julia"]):
return "fractal"
if any(kw in prompt_lower for kw in ["geometric", "shape", "circle", "square", "triangle"]):
return "geometric"
if any(kw in prompt_lower for kw in ["wave", "ocean", "sea", "water", "ripple"]):
return "waves"
# Hash-based selection for variety
h = int(hashlib.md5(prompt.encode()).hexdigest(), 16) % len(self.PATTERN_TYPES)
return self.PATTERN_TYPES[h]
def _render(self, pattern: str, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a pattern to a pixel array."""
pixels = np.zeros((h, w, 3), dtype=np.uint8)
if pattern == "gradient":
pixels = self._render_gradient(w, h, palette)
elif pattern == "radial":
pixels = self._render_radial(w, h, palette)
elif pattern == "noise":
pixels = self._render_noise(w, h, palette)
elif pattern == "fractal":
pixels = self._render_fractal(w, h, palette)
elif pattern == "geometric":
pixels = self._render_geometric(w, h, palette)
elif pattern == "waves":
pixels = self._render_waves(w, h, palette)
else:
pixels = self._render_gradient(w, h, palette)
return pixels
def _render_gradient(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a vertical gradient."""
pixels = np.zeros((h, w, 3), dtype=np.uint8)
for y in range(h):
t = y / max(h - 1, 1)
idx = t * (len(palette) - 1)
i0 = int(idx)
i1 = min(i0 + 1, len(palette) - 1)
frac = idx - i0
r = int(palette[i0][0] * (1 - frac) + palette[i1][0] * frac)
g = int(palette[i0][1] * (1 - frac) + palette[i1][1] * frac)
b = int(palette[i0][2] * (1 - frac) + palette[i1][2] * frac)
pixels[y, :] = [r, g, b]
return pixels
def _render_radial(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a radial gradient."""
pixels = np.zeros((h, w, 3), dtype=np.float32)
cx, cy = w / 2, h / 2
max_dist = math.sqrt(cx**2 + cy**2)
yy, xx = np.ogrid[:h, :w]
dist = np.sqrt((xx - cx)**2 + (yy - cy)**2) / max_dist
for c in range(3):
channel = np.zeros((h, w), dtype=np.float32)
for i in range(len(palette) - 1):
t0 = i / (len(palette) - 1)
t1 = (i + 1) / (len(palette) - 1)
mask = (dist >= t0) & (dist <= t1)
frac = (dist[mask] - t0) / max(t1 - t0, 1e-6)
channel[mask] = palette[i][c] * (1 - frac) + palette[i + 1][c] * frac
pixels[:, :, c] = channel
return np.clip(pixels, 0, 255).astype(np.uint8)
def _render_noise(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a noise-based texture."""
noise = np.random.rand(h, w)
pixels = np.zeros((h, w, 3), dtype=np.uint8)
for c in range(3):
channel = np.zeros((h, w), dtype=np.float32)
for i in range(len(palette) - 1):
t0 = i / (len(palette) - 1)
t1 = (i + 1) / (len(palette) - 1)
mask = (noise >= t0) & (noise <= t1)
frac = (noise[mask] - t0) / max(t1 - t0, 1e-6)
channel[mask] = palette[i][c] * (1 - frac) + palette[i + 1][c] * frac
pixels[:, :, c] = np.clip(channel, 0, 255)
return pixels
def _render_fractal(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a simple fractal (Mandelbrot-like)."""
pixels = np.zeros((h, w, 3), dtype=np.uint8)
max_iter = 50
# Create coordinate arrays
x_vals = np.linspace(-2.0, 1.0, w, dtype=np.float32)
y_vals = np.linspace(-1.5, 1.5, h, dtype=np.float32)
cx, cy = np.meshgrid(x_vals, y_vals)
zx = np.zeros((h, w), dtype=np.float32)
zy = np.zeros((h, w), dtype=np.float32)
iterations = np.zeros((h, w), dtype=np.float32)
for i in range(max_iter):
mask = zx**2 + zy**2 < 4
zx_new = zx[mask]**2 - zy[mask]**2 + cx[mask]
zy[mask] = 2 * zx[mask] * zy[mask] + cy[mask]
zx[mask] = zx_new
iterations[mask] = i
norm = iterations / max_iter
for c in range(3):
channel = np.zeros((h, w), dtype=np.float32)
for i in range(len(palette) - 1):
t0 = i / (len(palette) - 1)
t1 = (i + 1) / (len(palette) - 1)
mask = (norm >= t0) & (norm <= t1)
frac = (norm[mask] - t0) / max(t1 - t0, 1e-6)
channel[mask] = palette[i][c] * (1 - frac) + palette[i + 1][c] * frac
pixels[:, :, c] = np.clip(channel, 0, 255)
return pixels
def _render_geometric(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render geometric shapes."""
pixels = np.zeros((h, w, 3), dtype=np.uint8)
# Background
r, g, b = palette[0]
pixels[:, :] = [r, g, b]
# Draw circles
for i, (r, g, b) in enumerate(palette[1:]):
cx = (w * (i + 1)) // (len(palette) - 1)
cy = (h * (i + 1)) // (len(palette) - 1)
radius = min(w, h) // 6
yy, xx = np.ogrid[:h, :w]
mask = (xx - cx)**2 + (yy - cy)**2 <= radius**2
pixels[mask] = [r, g, b]
return pixels
def _render_waves(self, w: int, h: int,
palette: list[tuple[int, int, int]]) -> np.ndarray:
"""Render a wave pattern."""
pixels = np.zeros((h, w, 3), dtype=np.float32)
yy, xx = np.ogrid[:h, :w]
wave = (np.sin(xx * 0.05) + np.sin(yy * 0.03) + np.sin((xx + yy) * 0.02)) / 3
wave = (wave + 1) / 2 # normalize to 0-1
for c in range(3):
channel = np.zeros((h, w), dtype=np.float32)
for i in range(len(palette) - 1):
t0 = i / (len(palette) - 1)
t1 = (i + 1) / (len(palette) - 1)
mask = (wave >= t0) & (wave <= t1)
frac = (wave[mask] - t0) / max(t1 - t0, 1e-6)
channel[mask] = palette[i][c] * (1 - frac) + palette[i + 1][c] * frac
pixels[:, :, c] = channel
return np.clip(pixels, 0, 255).astype(np.uint8)
def _encode_bmp(self, pixels: np.ndarray) -> bytes:
"""Encode a pixel array as BMP format (no external deps)."""
h, w = pixels.shape[:2]
row_size = (w * 3 + 3) & ~3 # BMP rows are padded to 4 bytes
pixel_data_size = row_size * h
file_size = 54 + pixel_data_size
bmp = io.BytesIO()
# BMP header
bmp.write(b"BM")
bmp.write(struct.pack(" None:
"""Save generated image data to a file."""
if image_data.get("format") == "bmp":
b64 = image_data["base64"]
with open(path, "wb") as f:
f.write(base64.b64decode(b64))
elif image_data.get("format") == "svg":
with open(path, "w") as f:
f.write(image_data["svg"])
def get_stats(self) -> dict[str, Any]:
return {**self._stats}