| """ |
| Image generation service — calls modal_image_gen for FLUX.2-klein inference. |
| |
| C1 Compliance: Character consistency stack |
| - Locked seed; page i uses seed+i |
| - Identical character_description on every page |
| - Doodle as IP-Adapter image prompt (if provided) |
| """ |
|
|
| from config import FLUX_MODEL, GENERATION_PARAMS, page_seed |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
| DEFAULT_COLOR_ART_STYLE = ( |
| "children's crayon storybook illustration, bold black outlines, " |
| "flat bright colors, simple shapes" |
| ) |
| DEFAULT_COLORING_ART_STYLE = ( |
| "children's coloring book page, pure black ink outlines on pure white paper, " |
| "clean contour lines, no color, no gray, no shading, no texture, " |
| "no hatching, no pencil marks, open spaces to color" |
| ) |
|
|
|
|
| def generate_book_pages( |
| character_desc: str, |
| story_beats: list[str], |
| doodle: bytes = None, |
| art_style: str = "crayon drawing, children's book, colorful, simple shapes", |
| seed: int = None, |
| lora_repo: str = None, |
| tiny: bool = False |
| ) -> list[bytes]: |
| """ |
| Generate all 6 book pages via FLUX.2-klein on Modal. |
| |
| Uses one warm container call (C3) for efficiency. |
| Character consistency via: |
| - Locked seed per page |
| - Identical character_description on every page |
| - Doodle image as IP-Adapter prompt (if provided) |
| |
| Args: |
| character_desc: Visual description of character |
| story_beats: List of scene descriptions |
| doodle: Optional doodle image bytes for IP-Adapter |
| art_style: Art style prefix |
| seed: Base seed (default from config) |
| lora_repo: Optional LoRA repo |
| tiny: Use Tiny Mode (SD-Turbo, 4 steps) |
| |
| Returns: |
| List of PNG image bytes (6 pages) |
| """ |
| if seed is None: |
| seed = GENERATION_PARAMS.num_inference_steps |
|
|
| |
| |
| try: |
| import modal |
| canon = modal.Function.from_name("doodlebook-image-gen", "build_canonical") |
| page = modal.Function.from_name("doodlebook-image-gen", "render_page") |
| canonical = canon.remote(doodle or b"", art_style, seed, tiny) |
| args = [ |
| (canonical, character_desc, beat, art_style, seed + i + 1, tiny) |
| for i, beat in enumerate(story_beats) |
| ] |
| images = list(page.starmap(args)) |
| if images and all(images): |
| return images, "flux" |
| raise RuntimeError("parallel render returned empty pages") |
| except Exception as e: |
| logger.warning(f"Modal parallel image gen failed ({e}); trying single-call worker") |
|
|
| |
| try: |
| import modal |
| fn = modal.Function.from_name("doodlebook-image-gen", "generate_book_pages") |
| images = fn.remote( |
| character_desc, story_beats, doodle, art_style, seed, lora_repo, tiny |
| ) |
| return images, "flux" |
| except Exception as e: |
| logger.warning(f"Modal generation failed: {e}, using local sketch fallback") |
| return generate_placeholder_images(character_desc, story_beats, doodle=doodle), "sketch" |
|
|
|
|
| def generate_coloring_pages( |
| character_desc: str, |
| story_beats: list[str], |
| doodle: bytes = None, |
| source_color_imgs: list[bytes] = None, |
| art_style: str = DEFAULT_COLORING_ART_STYLE, |
| seed: int = None, |
| tiny: bool = False, |
| ) -> tuple[list[bytes], str]: |
| """Generate coloring pages directly with Modal FLUX line-art renders. |
| |
| Primary path: build one canonical character, then render each page directly |
| as line art. Fallback: derive outlines from the finished color pages. |
| """ |
| if seed is None: |
| seed = GENERATION_PARAMS.num_inference_steps |
|
|
| try: |
| import modal |
| canon = modal.Function.from_name("doodlebook-image-gen", "build_canonical") |
| page = modal.Function.from_name("doodlebook-image-gen", "render_coloring_page") |
| canonical = canon.remote(doodle or b"", art_style, seed, tiny) |
| args = [ |
| (canonical, character_desc, beat, art_style, seed + i + 101, tiny) |
| for i, beat in enumerate(story_beats) |
| ] |
| images = list(page.starmap(args)) |
| if images and all(images): |
| from services.coloring import _crispen |
| return ([_crispen(img) for img in images], "flux-direct-lineart") |
| raise RuntimeError("parallel coloring render returned empty pages") |
| except Exception as e: |
| logger.warning(f"Modal direct coloring gen failed ({e}); using traced fallback") |
|
|
| color_imgs = source_color_imgs |
| if color_imgs is None: |
| color_imgs, _ = generate_book_pages( |
| character_desc, |
| story_beats, |
| doodle=doodle, |
| art_style=DEFAULT_COLOR_ART_STYLE, |
| seed=seed, |
| tiny=tiny, |
| ) |
| from services.coloring import derive_coloring_pages |
| return derive_coloring_pages(color_imgs), "trace-fallback" |
|
|
|
|
| def generate_placeholder_images( |
| character_desc: str = "", |
| story_beats: list[str] = None, |
| doodle: bytes = None, |
| ) -> list[bytes]: |
| """ |
| Generate crayon-style PREVIEW illustrations (used when no GPU/FLUX is available). |
| |
| Key behaviour: if the child's `doodle` is provided, their ACTUAL drawing is cut |
| out and composited as the recurring hero on every page — so different drawings |
| produce different books (fixing "it builds the same no matter what I draw"). |
| Without a doodle, a drawn blob-creature is used, varied by the character name. |
| Deterministic for a given input. |
| """ |
| from PIL import Image |
| import io |
| import random |
|
|
| if story_beats is None: |
| story_beats = ["Once upon a time", "A discovery", "The journey", |
| "A little trouble", "Together", "The happy end"] |
|
|
| |
| vkey = sum(ord(c) for c in (character_desc or "hero")) or 7 |
| hero_palette = ["#ef6a3a", "#e0533a", "#d6517a", "#4a9fd6", "#2ba39a", "#8e63c4"] |
| hero = hero_palette[vkey % len(hero_palette)] |
| friend = "#2ba39a" if hero != "#2ba39a" else "#ef6a3a" |
| ink = "#2e2a26" |
|
|
| sky_colors = ["#dff1fb", "#fde9d6", "#e6f4e1", "#e3eefc", "#f3e6f6", "#fdf3d6"] |
| ground_colors = ["#cfe3a6", "#e9c79a", "#bfe3b0", "#cfe3a6", "#d9c7e8", "#e8dca0"] |
|
|
| |
| doodle_cut = _doodle_to_cartoon(doodle, fill_rgb=_hex(hero)) if doodle else None |
|
|
| |
| hero_spots = [(384, 300), (270, 312), (380, 312), (250, 312), (290, 312), (384, 300)] |
|
|
| def place_hero(img, rng, cx, cy, flip=False): |
| if doodle_cut is not None: |
| _paste_doodle(img, doodle_cut, cx, cy, target_h=210, flip=flip) |
| else: |
| _crayon_creature(img, rng, cx, cy + 30, hero, ink) |
|
|
| images = [] |
| for i, (sky, ground, beat) in enumerate(zip(sky_colors, ground_colors, story_beats)): |
| rng = random.Random(1000 + i + vkey) |
| img = Image.new("RGB", (768, 512), sky) |
|
|
| _crayon_band(img, rng, 0, 360, sky) |
| _crayon_band(img, rng, 360, 512, ground) |
|
|
| cx, cy = hero_spots[i] |
| if i == 0: |
| _crayon_sun(img, rng, 650, 90) |
| place_hero(img, rng, cx, cy) |
| elif i == 1: |
| place_hero(img, rng, cx, cy) |
| _crayon_sparkle(img, rng, 560, 200, "#f4c64a") |
| _crayon_sparkle(img, rng, 600, 260, "#d6517a") |
| elif i == 2: |
| _crayon_tree(img, rng, 140); _crayon_tree(img, rng, 610) |
| place_hero(img, rng, cx, cy, flip=True) |
| elif i == 3: |
| place_hero(img, rng, cx, cy) |
| _crayon_blob(img, rng, 560, 360, 70, "#9aa0a6", ink) |
| elif i == 4: |
| place_hero(img, rng, cx, cy) |
| _crayon_creature(img, rng, 500, 342, friend, ink) |
| _crayon_sparkle(img, rng, 392, 210, "#d6517a") |
| else: |
| place_hero(img, rng, cx, cy) |
| for _ in range(26): |
| x, y = rng.randint(80, 690), rng.randint(40, 190) |
| c = rng.choice(["#ef6a3a", "#2ba39a", "#f4c64a", "#d6517a", "#4a9fd6"]) |
| _crayon_sparkle(img, rng, x, y, c, size=rng.randint(6, 12)) |
|
|
| _paper_grain(img, rng, amount=2200) |
| buf = io.BytesIO() |
| img.save(buf, format="PNG") |
| images.append(buf.getvalue()) |
|
|
| return images |
|
|
|
|
| def _doodle_to_cartoon(doodle_bytes: bytes, fill_rgb=(239, 106, 58)): |
| """ |
| Turn a PHOTO of a child's drawing into a clean cartoon cut-out: |
| 1. find the paper (largest bright region) and crop to it — drops the desk/shadows |
| 2. trace the crayon/pencil strokes into bold black outlines (adaptive threshold) |
| 3. drop noise, fill the silhouette with a flat character color, ink the outlines |
| 4. return a transparent RGBA character — NOT the raw photo |
| |
| Falls back to a simple alpha cut-out if OpenCV/processing fails. |
| """ |
| from PIL import Image |
| import io |
| try: |
| import cv2 |
| import numpy as np |
|
|
| rgb = np.array(Image.open(io.BytesIO(doodle_bytes)).convert("RGB")) |
| h, w = rgb.shape[:2] |
| s = 900.0 / max(h, w) |
| if s < 1: |
| rgb = cv2.resize(rgb, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) |
| gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) |
|
|
| |
| |
| corners = [gray[0, 0], gray[0, -1], gray[-1, 0], gray[-1, -1]] |
| if float(np.mean(corners)) < 150: |
| blur = cv2.GaussianBlur(gray, (7, 7), 0) |
| _, paper = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) |
| cnts, _ = cv2.findContours(paper, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| if cnts: |
| c = max(cnts, key=cv2.contourArea) |
| if cv2.contourArea(c) > 0.12 * gray.size: |
| x, y, wc, hc = cv2.boundingRect(c) |
| pad = int(0.03 * max(wc, hc)) |
| gray = gray[y + pad:y + hc - pad, x + pad:x + wc - pad] |
| rgb = rgb[y + pad:y + hc - pad, x + pad:x + wc - pad] |
| if gray.size == 0: |
| raise ValueError("empty crop") |
| H, W = gray.shape |
|
|
| |
| g = cv2.GaussianBlur(gray, (3, 3), 0) |
| strokes = cv2.adaptiveThreshold( |
| g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, |
| blockSize=25, C=10, |
| ) |
| strokes = cv2.medianBlur(strokes, 3) |
|
|
| |
| num, lbl, stats, _ = cv2.connectedComponentsWithStats(strokes, 8) |
| clean = np.zeros_like(strokes) |
| min_area = max(15, int(0.0001 * strokes.size)) |
| for i in range(1, num): |
| if stats[i, cv2.CC_STAT_AREA] >= min_area: |
| clean[lbl == i] = 255 |
| strokes = clean |
| if int(strokes.sum()) < 255 * 40: |
| raise ValueError("no strokes found") |
|
|
| |
| |
| |
| sealed = cv2.dilate(strokes, np.ones((3, 3), np.uint8), iterations=2) |
| free = np.where(sealed == 0, 255, 0).astype(np.uint8) |
| framed = cv2.copyMakeBorder(free, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=255) |
| ffmask = np.zeros((framed.shape[0] + 2, framed.shape[1] + 2), np.uint8) |
| cv2.floodFill(framed, ffmask, (0, 0), 128) |
| filled = framed[1:-1, 1:-1] |
| body = np.where(filled != 128, 255, 0).astype(np.uint8) |
| |
| body = cv2.erode(body, np.ones((3, 3), np.uint8), iterations=2) |
| body = cv2.medianBlur(body, 5) |
|
|
| |
| strokes = cv2.dilate(strokes, np.ones((2, 2), np.uint8), iterations=1) |
| out = np.zeros((H, W, 4), np.uint8) |
| out[body > 0] = (fill_rgb[0], fill_rgb[1], fill_rgb[2], 255) |
| out[strokes > 0] = (38, 30, 28, 255) |
| alpha = np.maximum(body, strokes) |
| out[..., 3] = alpha |
|
|
| ys, xs = np.where(alpha > 0) |
| if len(xs) == 0: |
| raise ValueError("empty character") |
| out = out[ys.min():ys.max() + 1, xs.min():xs.max() + 1] |
| return Image.fromarray(out, "RGBA") |
|
|
| except Exception as e: |
| logger.warning(f"cartoonify failed ({e}); using simple cut-out") |
| im = Image.open(io.BytesIO(doodle_bytes)).convert("RGBA") |
| if max(im.size) > 900: |
| im.thumbnail((900, 900)) |
| px = im.getdata() |
| im.putdata([(r, g, b, 0) if (r > 232 and g > 232 and b > 232) else (r, g, b, a) |
| for r, g, b, a in px]) |
| bbox = im.getbbox() |
| return im.crop(bbox) if bbox else im |
|
|
|
|
| def _paste_doodle(img, cut, cx, cy, target_h=210, flip=False): |
| """Scale the cut-out character to target height and paste it centered at (cx, cy).""" |
| from PIL import Image |
| c = cut |
| if flip: |
| c = c.transpose(Image.FLIP_LEFT_RIGHT) |
| w, h = c.size |
| if h == 0: |
| return |
| scale = target_h / h |
| new = c.resize((max(1, int(w * scale)), target_h), Image.LANCZOS) |
| x = int(cx - new.width / 2) |
| y = int(cy - new.height / 2) |
| img.paste(new, (x, y), new) |
|
|
|
|
| |
|
|
| def _hex(c): |
| c = c.lstrip("#") |
| return tuple(int(c[j:j+2], 16) for j in (0, 2, 4)) |
|
|
|
|
| def _crayon_band(img, rng, y0, y1, color): |
| """Fill a horizontal band with short jittered crayon strokes.""" |
| from PIL import ImageDraw |
| d = ImageDraw.Draw(img) |
| base = _hex(color) |
| for _ in range(900): |
| x = rng.randint(0, 768); y = rng.randint(y0, y1) |
| ln = rng.randint(8, 22); jit = rng.randint(-12, 12) |
| shade = rng.randint(-14, 10) |
| col = tuple(max(0, min(255, v + shade)) for v in base) |
| d.line([(x, y), (x + ln, y + jit // 3)], fill=col, width=rng.randint(2, 4)) |
|
|
|
|
| def _crayon_blob(img, rng, cx, cy, r, color, ink): |
| """A filled wobbly blob with a crayon outline.""" |
| from PIL import ImageDraw |
| import math |
| d = ImageDraw.Draw(img) |
| pts = [] |
| for a in range(0, 360, 18): |
| rr = r + rng.randint(-r // 8, r // 8) |
| pts.append((cx + rr * math.cos(math.radians(a)), |
| cy + rr * math.sin(math.radians(a)))) |
| d.polygon(pts, fill=_hex(color)) |
| |
| base = _hex(color) |
| for _ in range(int(r * 6)): |
| x = rng.randint(cx - r, cx + r); y = rng.randint(cy - r, cy + r) |
| if (x - cx) ** 2 + (y - cy) ** 2 <= (r - 4) ** 2: |
| shade = rng.randint(-22, 4) |
| col = tuple(max(0, min(255, v + shade)) for v in base) |
| d.line([(x, y), (x + rng.randint(4, 10), y + rng.randint(-3, 3))], fill=col, width=2) |
| _wobble_outline(d, rng, pts, ink) |
|
|
|
|
| def _wobble_outline(d, rng, pts, ink, width=4): |
| col = _hex(ink) |
| for j in range(len(pts)): |
| a = pts[j]; b = pts[(j + 1) % len(pts)] |
| mx = (a[0] + b[0]) / 2 + rng.randint(-2, 2) |
| my = (a[1] + b[1]) / 2 + rng.randint(-2, 2) |
| d.line([a, (mx, my), b], fill=col, width=width, joint="curve") |
|
|
|
|
| def _crayon_creature(img, rng, cx, cy, color, ink): |
| """A friendly rounded blob-creature with a big face — far cuter than a stick figure.""" |
| from PIL import ImageDraw |
| _crayon_blob(img, rng, cx, cy, 64, color, ink) |
| _crayon_blob(img, rng, cx, cy - 78, 40, color, ink) |
| d = ImageDraw.Draw(img) |
| inkc = _hex(ink) |
| |
| for dx in (-26, 26): |
| d.line([(cx + dx, cy + 50), (cx + dx + rng.randint(-4, 4), cy + 92)], fill=inkc, width=6) |
| for dx in (-58, 58): |
| d.line([(cx, cy - 6), (cx + dx, cy + 18 + rng.randint(-4, 4))], fill=inkc, width=5) |
| |
| for dx in (-14, 14): |
| d.ellipse([cx + dx - 9, cy - 92, cx + dx + 9, cy - 74], fill="white", outline=inkc, width=2) |
| d.ellipse([cx + dx - 3, cy - 86, cx + dx + 3, cy - 80], fill=inkc) |
| |
| for dx in (-22, 22): |
| d.ellipse([cx + dx - 7, cy - 72, cx + dx + 7, cy - 60], fill=(246, 170, 150)) |
| d.arc([cx - 14, cy - 78, cx + 14, cy - 58], 10, 170, fill=inkc, width=4) |
|
|
|
|
| def _crayon_sun(img, rng, x, y): |
| from PIL import ImageDraw |
| import math |
| _crayon_blob(img, rng, x, y, 38, "#f4c64a", "#e0a92e") |
| d = ImageDraw.Draw(img) |
| for a in range(0, 360, 30): |
| x2 = x + 58 * math.cos(math.radians(a)); y2 = y + 58 * math.sin(math.radians(a)) |
| d.line([(x, y), (x2, y2)], fill=(224, 169, 46), width=4) |
|
|
|
|
| def _crayon_tree(img, rng, x): |
| from PIL import ImageDraw |
| d = ImageDraw.Draw(img) |
| d.line([(x, 420), (x + rng.randint(-4, 4), 320)], fill=(120, 84, 60), width=12) |
| _crayon_blob(img, rng, x, 300, 56, "#74b85a", "#4f7d3a") |
|
|
|
|
| def _crayon_sparkle(img, rng, x, y, color, size=14): |
| from PIL import ImageDraw |
| d = ImageDraw.Draw(img) |
| c = _hex(color) |
| d.line([(x, y - size), (x, y + size)], fill=c, width=4) |
| d.line([(x - size, y), (x + size, y)], fill=c, width=4) |
| d.line([(x - size // 2, y - size // 2), (x + size // 2, y + size // 2)], fill=c, width=3) |
| d.line([(x - size // 2, y + size // 2), (x + size // 2, y - size // 2)], fill=c, width=3) |
|
|
|
|
| def _paper_grain(img, rng, amount=2000): |
| """Sprinkle subtle light/dark specks so it reads as paper, not flat pixels.""" |
| px = img.load() |
| w, h = img.size |
| for _ in range(amount): |
| x = rng.randint(0, w - 1); y = rng.randint(0, h - 1) |
| r, g, b = px[x, y] |
| s = rng.choice([-12, -8, 8, 12]) |
| px[x, y] = (max(0, min(255, r + s)), max(0, min(255, g + s)), max(0, min(255, b + s))) |
|
|