DoodleBook / services /coloring.py
Codex
DoodleBook: ZeroGPU app with parallel TTS, heartbeat, queue
6b7009d
Raw
History Blame Contribute Delete
6.53 kB
"""
Coloring-book service — turn the color pages into clean coloring-book outlines.
Primary path: hand each finished color page back to FLUX (Modal `render_lineart`)
which REDRAWS it as clean line art. FLUX understands the scene (kid + clouds +
hills) and traces shape boundaries, so busy/textured backgrounds no longer
shatter into speckle the way the OpenCV edge-trace did. A tiny local `_crispen`
then forces it to pure black-on-white. Falls back to the OpenCV region tracer,
then to PIL, then to the original — so it never raises and is never worse than
before.
"""
import io
import logging
logger = logging.getLogger(__name__)
def _crispen(png_bytes: bytes) -> bytes:
"""Force FLUX line art to crisp black-on-white: threshold + despeckle.
FLUX may leave faint gray or off-white; coloring pages must be pure outlines.
Cheap, local, no GPU. Returns the input unchanged if processing fails."""
try:
import cv2
import numpy as np
from PIL import Image
gray = np.array(Image.open(io.BytesIO(png_bytes)).convert("L"))
# anything not near-white becomes a candidate line, then threshold hard
gray = cv2.medianBlur(gray, 3)
_, ink = cv2.threshold(gray, 214, 255, cv2.THRESH_BINARY_INV) # ink=255
ink = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
# drop tiny specks, keep real strokes
num, lbl, stats, _ = cv2.connectedComponentsWithStats(ink, 8)
clean = np.zeros_like(ink)
min_area = max(14, int(0.00025 * ink.size))
for i in range(1, num):
if stats[i, cv2.CC_STAT_AREA] >= min_area:
clean[lbl == i] = 255
clean = cv2.dilate(clean, np.ones((2, 2), np.uint8), iterations=1)
out = Image.fromarray(255 - clean).convert("RGB") # black ink on white
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
logger.warning(f"_crispen failed ({e}); using FLUX line art as-is")
return png_bytes
def _to_line_art_opencv(png_bytes: bytes) -> bytes:
"""
Convert one colored page into CLEAN coloring-book outlines: the OUTLINE of
each region (character, clouds, mountain, sky, ground) with NO inner crayon
hatching/texture inside a region.
Why not edge detection: Canny traces every crayon stroke, so shaded areas
(mountains, ground) fill with speckly internal lines. Instead we FLATTEN the
image into a few flat color regions and trace only the boundaries BETWEEN
regions — giving silhouettes a child can color inside.
Steps: bilateral-smooth (kill crayon texture) -> k-means quantize to ~6 flat
colors -> median-clean the label map (dissolve tiny texture regions) -> mark
pixels where the region label changes -> despeckle. K=6 + median 7 is the
sweet spot: fewer colors / stronger median dissolves the character too. Fast
(~0.4s). Falls back to a PIL contour filter, then the original, so it never
raises.
"""
try:
import cv2
import numpy as np
from PIL import Image
rgb = np.array(Image.open(io.BytesIO(png_bytes)).convert("RGB"))
h, w = rgb.shape[:2]
# 1) flatten crayon texture/shading (keep the big shape edges)
sm = cv2.bilateralFilter(rgb, 9, 120, 120)
sm = cv2.bilateralFilter(sm, 9, 120, 120)
# 2) quantize colors into a few flat regions
Z = sm.reshape(-1, 3).astype(np.float32)
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
_, labels, _ = cv2.kmeans(Z, 6, None, crit, 3, cv2.KMEANS_PP_CENTERS)
lab = labels.reshape(h, w).astype(np.uint8)
# 3) dissolve tiny speckle regions so only real shapes survive
lab = cv2.medianBlur(lab, 7)
lab = cv2.medianBlur(lab, 7)
# 4) trace ONLY the boundaries between regions
L = lab.astype(np.int32)
edges = np.zeros((h, w), np.uint8)
edges[:, :-1] |= (L[:, :-1] != L[:, 1:]).astype(np.uint8) * 255
edges[:-1, :] |= (L[:-1, :] != L[1:, :]).astype(np.uint8) * 255
edges = cv2.dilate(edges, np.ones((2, 2), np.uint8), iterations=1)
# 5) despeckle: drop tiny boundary fragments, keep real outlines
num, lbl, stats, _ = cv2.connectedComponentsWithStats(edges, 8)
clean = np.zeros_like(edges)
min_area = max(10, int(0.0003 * edges.size))
for i in range(1, num):
if stats[i, cv2.CC_STAT_AREA] >= min_area:
clean[lbl == i] = 255
out = Image.fromarray(255 - clean).convert("RGB") # invert: black on white
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
logger.warning(f"to_line_art region path failed ({e}); trying PIL contour")
return _to_line_art_pil(png_bytes)
def _to_line_art_pil(png_bytes: bytes) -> bytes:
"""No-OpenCV fallback: PIL CONTOUR gives dark outlines on a light ground."""
try:
from PIL import Image, ImageFilter, ImageOps
im = Image.open(io.BytesIO(png_bytes)).convert("L")
contour = im.filter(ImageFilter.CONTOUR) # light bg, dark edges
contour = ImageOps.autocontrast(contour)
out = contour.convert("RGB")
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
logger.error(f"PIL line-art fallback failed ({e}); returning original")
return png_bytes
def derive_coloring_pages(color_imgs: list[bytes]) -> list[bytes]:
"""Clean coloring-book outline for every page.
Primary: FLUX `render_lineart` on Modal, fanned out across containers via
.starmap (concurrent), then crispened to pure black-on-white. If Modal is
unavailable or returns a blank, fall back per-page to the OpenCV tracer.
"""
# 1) FLUX line art (parallel), then local crispen
try:
import modal
fn = modal.Function.from_name("doodlebook-image-gen", "render_lineart")
raw = list(fn.starmap([(img, 7 + i) for i, img in enumerate(color_imgs)]))
if raw and all(raw):
return [_crispen(r) for r in raw]
raise RuntimeError("render_lineart returned empty page(s)")
except Exception as e:
logger.warning(f"FLUX line art failed ({e}); OpenCV outline fallback")
# 2) Fallback: derive outlines locally from the color pages
return [_to_line_art_opencv(img) for img in color_imgs]