| """ |
| 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")) |
| |
| gray = cv2.medianBlur(gray, 3) |
| _, ink = cv2.threshold(gray, 214, 255, cv2.THRESH_BINARY_INV) |
| ink = cv2.morphologyEx(ink, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8)) |
| |
| 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") |
| 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] |
|
|
| |
| sm = cv2.bilateralFilter(rgb, 9, 120, 120) |
| sm = cv2.bilateralFilter(sm, 9, 120, 120) |
|
|
| |
| 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) |
|
|
| |
| lab = cv2.medianBlur(lab, 7) |
| lab = cv2.medianBlur(lab, 7) |
|
|
| |
| 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) |
|
|
| |
| 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") |
| 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) |
| 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. |
| """ |
| |
| 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") |
|
|
| |
| return [_to_line_art_opencv(img) for img in color_imgs] |
|
|