Claude Skill

rembg-ops

Transparent-PNG cutouts / background removal for flat illustration, sticker, cartoon and avatar art — rembg (isnet-anime) with a deterministic fallback ladder for the cases ML drops. Triggers on: rembg, remove background, background removal, transparent PNG, cutout, die-cut stick

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_rembg-ops-3dfaf0b.zip · 11 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/rembg-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

rembg Operations — cutouts for flat illustration & sticker art

Turn opaque, flat-background illustration (avatars, stickers, cartoon/vector art, generated character portraits) into clean transparent PNGs — without regenerating the source. rembg's ML models are the default engine; the value this skill adds is the deterministic fallback ladder for the cases pure ML gets wrong on flat-colour art.

The one thing to know

rembg is a semantic model, not a chroma keyer. isnet-anime (the model tuned for outlined cartoon art) segments the salient character and ignores background colour — so it cannot be "helped" with a greenscreen, and it fails in two predictable ways on flat art:

  1. Pale / low-salience subjects get dropped (a near-white moon, a mint UFO on a light background read as "not the subject").
  2. Baked drop-shadows survive as a grey ghost or an opaque offset blob.

Both are fixed deterministically because flat sticker art always has one flat background colour + a sealing outline. That geometry makes a corner flood-fill colour-key exact, regardless of how pale the subject is. The axis that matters is shadow-vs-no-shadow, never greenscreen-vs-not.

Quickstart

# default: rembg isnet-anime, auto-fallback to colour-key if the subject collapses
python scripts/cutout.py "avatars/*.png" --out cutouts/ --contact-sheet

The --contact-sheet renders every cutout on a checkerboard so bad mattes jump out. Then triage failures with the ladder below. --json | jq '.data[] | select(.coverage < 0.12)' flags collapsed subjects programmatically.

Method ladder — escalate only for the images that need it

Symptom on the contact sheet Fix Flag
Clean die-cut — (rembg isnet-anime) default
Subject gone / faint ghost (pale subject) flat-bg colour-key --method colorkey
Translucent subject reads see-through (glass, clear cup) colour-key forces the sealed interior opaque --method colorkey
Semi-transparent grey shadow ghost binarise alpha — drops the ~50% shadow, keeps the 100% subject --flatten-alpha 170
Opaque baked offset shadow (survives binarise) colour-aware: drop dark pixels not hugging a coloured fill --strip-offset-shadow

auto (the default) already does rembg→colour-key when the silhouette collapses (--min-cov, default 0.10). The post-processing flags are opt-in per image because detecting a shadow ghost automatically is unreliable — eyeball the sheet, escalate the few that need it. See references/cutout-methods.md for how each method works and why.

Why no regeneration

Every method here alpha-masks the original pixels — it never re-renders the subject. That's what lets you cut out AI-generated character art (or any hand-drawn asset) without changing how the character looks. If your generator is text-to-image only (no img2img / transparent-output flag, e.g. most portrait CLIs), local background removal is the only way to get transparency without a character-drifting re-roll.

The intended downstream shape

Cut once to transparent PNG, then composite in the app: transparent PNG over a CSS background colour + a CSS hard-offset shadow (drawn in CSS, not baked into the image). This is what makes a colour-picker possible — one asset, any background. Never bake the shadow back into the PNG.

Gotchas

  • isnet-anime vs u2net: the default u2net model leaves a grey ghost of a baked shadow and softer edges on cartoon art — always pass --model isnet-anime (this skill's default) for flat/outlined work. u2net is fine for photos.
  • First rembg run downloads a ~170 MB model (cached after). Fully offline work → --method colorkey.
  • Colour-key keeps bg-coloured regions inside the subject (they don't touch a border) — a subject with a same-as-background patch stays intact. It only removes background reachable from the image edge.
  • Disconnected decorations (sparkle stars floating on the background) are kept by colour-key as islands — usually what you want for a sticker; crop them out first if not.
  • --strip-offset-shadow assumes a dark shadow + coloured subject fills. On an all-dark subject it will over-eat; use --flatten-alpha there instead.

Dependencies

pillow + numpy (required); scipy (optional, faster flood-fill); rembg (only for the rembg/auto ML path — colorkey and the post-processors are pure Pillow/numpy and need no model download).

uv pip install pillow numpy scipy rembg   # rembg optional
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • cutout-methods.md 5.1 KB
      # Cutout methods — how each works and when it wins
      
      Companion to [SKILL.md](../SKILL.md). The skill body owns the *decision ladder*;
      this file owns the *mechanism* — why each method behaves as it does, so you can
      reach for the right one and tune it.
      
      ## Why flat sticker art is a special case
      
      General background removal is hard because photographic backgrounds are busy and
      the subject boundary is fuzzy. Flat illustration is the opposite: **one flat
      background colour, a thick sealing outline, hard-edged flat fills.** That structure
      is what makes a deterministic colour-key exact — and it's exactly the structure
      ML saliency models *don't* need, so they sometimes fight it. Pick the method that
      exploits the structure you have.
      
      ## 1. `rembg` (ML segmentation) — the default engine
      
      rembg wraps U²-Net / IS-Net segmentation models. This skill defaults to
      **`isnet-anime`**: IS-Net (the "Dichotomous Image Segmentation" architecture,
      Qin et al. ECCV 2022) fine-tuned on anime/cartoon line-art. It learned that "the
      subject" is the ink-outlined, flat-filled character region — the visual grammar of
      sticker art — so it:
      
      - cuts crisply *at* the black outline (not a soft photographic feather), and
      - treats a baked drop-shadow as *not the character* and usually drops it.
      
      It's **colour-agnostic** (semantic, not chroma) — a greenscreen background helps it
      none. Its failure mode is **low salience**: a pale subject that doesn't "pop"
      against a light background (a cream moon, a mint UFO) is read as background and
      dropped. Detect this as a collapsed alpha coverage (`< --min-cov`).
      
      `u2net` (rembg's default model) is trained on general/photographic salient objects
      — on flat cartoon art it softens edges and half-keeps baked shadows as a grey
      ghost. Use it for photos, not stickers.
      
      ## 2. `colorkey` (flat-bg flood-fill) — the deterministic recoverer
      
      Sample the background colour from the four corners (median), mark pixels within
      `--tol` of it, then **flood-fill from the image border** and transparent-out only
      the background region reachable from an edge. Everything the outline seals off
      stays fully opaque.
      
      Why it's the workhorse fallback:
      
      - **Ignores subject brightness** — recovers the pale subjects rembg drops.
      - **Forces translucent subjects opaque** — a clear-plastic cup or glass that rembg
        gives partial alpha becomes solid, because the whole outline-sealed interior is
        kept at alpha 255.
      - **Keeps interior background-coloured regions** — a bg-colour patch *inside* the
        subject doesn't touch a border, so it survives (flood only removes edge-reachable
        bg). This is the difference from a naive global colour replace.
      - **Deterministic** — no model, no download, same output every run.
      
      Its blind spot: a **baked drop-shadow** is a distinct dark shape sitting *on* the
      background. Colour-key removes the bg colour but leaves the shadow (it isn't
      bg-coloured). Hence the two shadow post-processors below.
      
      Tuning: raise `--tol` if a slightly noisy/gradient background leaves a colour fringe;
      lower it if the key eats into a subject colour close to the background.
      
      ## 3. `--flatten-alpha T` — kill a semi-transparent shadow ghost
      
      When rembg keeps a baked shadow as a **~50%-opaque** ghost (you can see the
      checkerboard through it) while the subject is fully opaque, binarise the alpha:
      `alpha = 255 if alpha >= T else 0`. The translucent shadow drops; the opaque
      subject stays. `T≈170` cleanly separates a half-opaque shadow from a solid subject.
      Side effect: it hardens anti-aliased edges to a die-cut — desirable for stickers,
      and invisible at avatar size.
      
      Fails when the shadow is **fully opaque** (nothing to threshold) — use method 4.
      
      ## 4. `--strip-offset-shadow` — remove an opaque baked offset shadow
      
      The hard case: a solid, offset drop-shadow that survives binarise. Colour-aware
      removal:
      
      1. colour-key the background (method 2) → subject + shadow remain, subject opaque.
      2. classify **dark** pixels (`luma < ~80`) = outline **and** shadow.
      3. classify **coloured** pixels = the subject's non-dark fills.
      4. dilate the coloured region a few px; **keep a dark pixel only if it hugs a
         coloured fill** (that's the outline) and **drop dark pixels standing alone**
         (that's the offset shadow, which sits behind/beside the subject, away from any
         fill).
      
      Keeps the subject's own outline and any outlined decorations (their dark edge hugs
      a light interior); removes the detached offset shadow. Assumes a dark shadow and
      coloured subject fills — on an all-dark subject it over-eats, so prefer
      `--flatten-alpha` there.
      
      ## Choosing quickly from a contact sheet
      
      Render `--contact-sheet` (every cutout on a checkerboard) and scan:
      
      - **Missing / ghostly** → `--method colorkey` (pale subject).
      - **See-through where it should be solid** → `--method colorkey` (translucent subject).
      - **Grey haze offset behind the subject** → `--flatten-alpha 170`.
      - **Solid dark shape offset behind the subject** → `--strip-offset-shadow`.
      - **Clean** → leave it (rembg got it).
      
      Batch the whole set on `auto`, then re-run only the handful that need escalation
      into the same `--out` dir (they overwrite by name).
      
  • scripts
    • cutout.py 12.1 KB
      #!/usr/bin/env python3
      """Transparent-PNG cutouts for flat illustration / sticker / cartoon art.
      
      rembg's ML models are the default engine, but they DROP pale/low-salience
      subjects and mis-handle baked drop-shadows on flat-colour art. This script wraps
      rembg (isnet-anime) with a deterministic fallback ladder that exploits the one
      thing flat sticker art always has — a single flat background colour and a sealing
      outline — so the hard cases (pale UFO, near-white moon, translucent cup, baked
      offset shadow) come out clean without regenerating the source pixels.
      
      Methods (see references/cutout-methods.md for when each applies):
        rembg      ML segmentation (isnet-anime) — flat cartoon/outlined art, ~90%.
        colorkey   flood-fill the flat bg from the corners; keep everything the outline
                   seals off, fully opaque. Recovers subjects rembg drops AND forces
                   translucent subjects opaque. Deterministic; ignores subject colour.
        auto       rembg first; if the subject silhouette collapses (coverage < --min-cov)
                   fall back to colorkey. The default.
      Post-processing (opt-in, for baked shadows rembg keeps as a ghost):
        --flatten-alpha T      binarise alpha at T — drops semi-transparent shadow ghosts.
        --strip-offset-shadow  colour-aware: drop dark pixels not hugging a coloured fill
                               (removes an opaque baked offset shadow, keeps the outline).
      
      Usage:   cutout.py INPUT... [--out DIR] [--method auto|rembg|colorkey]
                          [--model NAME] [--tol N] [--min-cov F]
                          [--flatten-alpha T] [--strip-offset-shadow] [--contact-sheet]
                          [--json]
      Input:   image path(s), a directory, or a glob (argv only; no stdin).
      Output:  stdout = per-image result rows (plain TSV, or --json envelope). Data only.
      Stderr:  progress, backend notices, warnings, errors.
      Exit:    0 all ok, 2 usage, 3 input not found, 5 no imaging backend
               (Pillow/numpy missing), 10 one or more images failed / collapsed.
      
      Examples:
        cutout.py avatar.png --out cutouts/
        cutout.py "src/*.png" --out cutouts/ --contact-sheet
        cutout.py moon.png --method colorkey --out cutouts/        # pale subject
        cutout.py hero.png --flatten-alpha 170 --out cutouts/      # shadow ghost
        cutout.py crystal.png --strip-offset-shadow --out cutouts/ # opaque baked shadow
        cutout.py "*.png" --out cutouts/ --json | jq '.data[] | select(.coverage < 0.12)'
      """
      from __future__ import annotations
      
      import argparse
      import glob as globmod
      import json
      import os
      import sys
      
      EXIT_OK, EXIT_USAGE, EXIT_NOINPUT, EXIT_NOBACKEND, EXIT_FAIL = 0, 2, 3, 5, 10
      
      
      def log(msg: str) -> None:
          print(msg, file=sys.stderr)
      
      
      def _need_backend():
          try:
              import numpy  # noqa: F401
              from PIL import Image  # noqa: F401
          except Exception as e:  # pragma: no cover - env-dependent
              log(f"error: Pillow + numpy are required ({e}). pip/uv install pillow numpy")
              sys.exit(EXIT_NOBACKEND)
      
      
      def collect_inputs(patterns):
          files = []
          for p in patterns:
              if os.path.isdir(p):
                  files += sorted(globmod.glob(os.path.join(p, "*.png")))
              elif any(ch in p for ch in "*?["):
                  files += sorted(globmod.glob(p))
              else:
                  files.append(p)
          missing = [f for f in files if not os.path.isfile(f)]
          return files, missing
      
      
      # ── methods ────────────────────────────────────────────────────────────────
      def _bg_color(arr, k=12):
          import numpy as np
          corners = np.concatenate([
              arr[:k, :k].reshape(-1, 3), arr[:k, -k:].reshape(-1, 3),
              arr[-k:, :k].reshape(-1, 3), arr[-k:, -k:].reshape(-1, 3),
          ])
          return np.median(corners, axis=0)
      
      
      def _flood_bg_mask(near):
          """Pixels near-bg AND reachable from the image border → background.
          Keeps bg-coloured regions *inside* the subject (they don't touch an edge)."""
          import numpy as np
          try:
              from scipy import ndimage
              lab, _ = ndimage.label(near)
              border = np.concatenate([lab[0, :], lab[-1, :], lab[:, 0], lab[:, -1]])
              keep = np.zeros(lab.max() + 1, bool)
              keep[np.unique(border[border > 0])] = True
              return keep[lab]
          except Exception:
              from collections import deque
              h, w = near.shape
              vis = np.zeros((h, w), bool)
              dq = deque()
              for x in range(w):
                  for y in (0, h - 1):
                      if near[y, x] and not vis[y, x]:
                          vis[y, x] = True; dq.append((y, x))
              for y in range(h):
                  for x in (0, w - 1):
                      if near[y, x] and not vis[y, x]:
                          vis[y, x] = True; dq.append((y, x))
              while dq:
                  y, x = dq.popleft()
                  for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                      ny, nx = y + dy, x + dx
                      if 0 <= ny < h and 0 <= nx < w and not vis[ny, nx] and near[ny, nx]:
                          vis[ny, nx] = True; dq.append((ny, nx))
              return vis
      
      
      def method_colorkey(img, tol):
          import numpy as np
          from PIL import Image
          rgb = img.convert("RGB")
          arr = np.asarray(rgb).astype(np.int16)
          near = np.abs(arr - _bg_color(arr)).sum(axis=2) < tol
          bg = _flood_bg_mask(near)
          alpha = np.where(bg, 0, 255).astype(np.uint8)
          return Image.fromarray(np.dstack([np.asarray(rgb), alpha]))
      
      
      def method_rembg(img, model):
          try:
              from rembg import remove, new_session
          except Exception as e:
              raise RuntimeError(f"rembg not available ({e}); use --method colorkey") from e
          return remove(img.convert("RGBA"), session=new_session(model))
      
      
      def _dilate(mask, r):
          import numpy as np
          m = mask.copy()
          for _ in range(r):
              d = np.zeros_like(m)
              d[1:, :] |= m[:-1, :]; d[:-1, :] |= m[1:, :]
              d[:, 1:] |= m[:, :-1]; d[:, :-1] |= m[:, 1:]
              m = m | d
          return m
      
      
      def post_flatten_alpha(cut, T):
          import numpy as np
          from PIL import Image
          arr = np.array(cut.convert("RGBA"))
          arr[:, :, 3] = np.where(arr[:, :, 3] >= T, 255, 0).astype(np.uint8)
          return Image.fromarray(arr)
      
      
      def post_strip_offset_shadow(img, tol, dark=80, dilate=7):
          """Key the bg, then drop dark pixels that don't hug a coloured fill — i.e. an
          opaque baked offset shadow — while keeping the subject's own outline."""
          import numpy as np
          from PIL import Image
          rgb = img.convert("RGB")
          arr = np.asarray(rgb).astype(np.int16)
          near = np.abs(arr - _bg_color(arr)).sum(axis=2) < tol
          fg = ~_flood_bg_mask(near)
          gray = np.asarray(rgb).astype(np.float32).mean(axis=2)
          is_dark = gray < dark
          colored = fg & (~is_dark)
          keep = colored | (is_dark & _dilate(colored, dilate))
          alpha = np.where(keep, 255, 0).astype(np.uint8)
          return Image.fromarray(np.dstack([np.asarray(rgb), alpha]))
      
      
      def coverage(cut):
          import numpy as np
          return float((np.array(cut.convert("RGBA"))[:, :, 3] > 16).mean())
      
      
      def checker(w, h, s=16):
          from PIL import Image
          import numpy as np
          yy, xx = np.mgrid[0:h, 0:w]
          board = ((xx // s + yy // s) % 2).astype(np.uint8)
          rgb = np.where(board[..., None], 185, 225).astype(np.uint8).repeat(3, axis=2)
          return Image.fromarray(rgb, "RGB")
      
      
      # ── driver ───────────────────────────────────────────────────────────────
      def process_one(path, args):
          from PIL import Image
          img = Image.open(path)
          method = args.method
          if method == "colorkey":
              cut = method_colorkey(img, args.tol); used = "colorkey"
          elif method == "rembg":
              cut = method_rembg(img, args.model); used = f"rembg:{args.model}"
          else:  # auto
              try:
                  cut = method_rembg(img, args.model); used = f"rembg:{args.model}"
                  if coverage(cut) < args.min_cov:
                      log(f"  {os.path.basename(path)}: rembg collapsed → colorkey")
                      cut = method_colorkey(img, args.tol); used = "colorkey(fallback)"
              except RuntimeError as e:
                  log(f"  {e}"); cut = method_colorkey(img, args.tol); used = "colorkey(no-rembg)"
          if args.strip_offset_shadow:
              cut = post_strip_offset_shadow(img, args.tol); used += "+strip-shadow"
          if args.flatten_alpha is not None:
              cut = post_flatten_alpha(cut, args.flatten_alpha); used += f"+flatten@{args.flatten_alpha}"
          return cut, used
      
      
      def main(argv=None):
          ap = argparse.ArgumentParser(add_help=False)
          ap.add_argument("inputs", nargs="*")
          ap.add_argument("--out", default=".")
          ap.add_argument("--method", choices=("auto", "rembg", "colorkey"), default="auto")
          ap.add_argument("--model", default="isnet-anime")
          ap.add_argument("--tol", type=int, default=42, help="colorkey bg match tolerance (sum of 3 channels)")
          ap.add_argument("--min-cov", type=float, default=0.10, help="auto: rembg alpha coverage below this → colorkey")
          ap.add_argument("--flatten-alpha", type=int, default=None, metavar="T")
          ap.add_argument("--strip-offset-shadow", action="store_true")
          ap.add_argument("--contact-sheet", action="store_true")
          ap.add_argument("--json", action="store_true")
          ap.add_argument("-h", "--help", action="store_true")
          args = ap.parse_args(argv)
      
          if args.help:
              print(__doc__); return EXIT_OK
          if not args.inputs:
              log("usage: cutout.py INPUT... [--out DIR] [--method ...]  (see --help)")
              return EXIT_USAGE
      
          _need_backend()
          files, missing = collect_inputs(args.inputs)
          if missing:
              log(f"error: input not found: {missing[0]}")
              return EXIT_NOINPUT
          if not files:
              log("error: no input images matched")
              return EXIT_NOINPUT
      
          os.makedirs(args.out, exist_ok=True)
          results, failed, cuts = [], 0, []
          for path in files:
              name = os.path.splitext(os.path.basename(path))[0]
              try:
                  cut, used = process_one(path, args)
                  cov = coverage(cut)
                  op = os.path.join(args.out, f"{name}.png")
                  cut.save(op)
                  cuts.append((name, cut))
                  low = cov < args.min_cov
                  failed += low
                  results.append({"name": name, "output": op, "method": used,
                                  "coverage": round(cov, 4), "ok": not low})
                  log(f"  {'WARN' if low else 'ok'}  {name:16s} {used:24s} cov {cov*100:5.1f}%")
              except Exception as e:
                  failed += 1
                  results.append({"name": name, "input": path, "method": args.method,
                                  "coverage": 0.0, "ok": False, "error": str(e)})
                  log(f"  FAIL  {name}: {e}")
      
          if args.contact_sheet and cuts:
              import math
              from PIL import Image, ImageDraw, ImageFont
              cell, cols, pad, lab = 200, 6, 8, 20
              rows = math.ceil(len(cuts) / cols)
              grid = Image.new("RGB", (cols * cell + (cols + 1) * pad,
                                       rows * (cell + lab) + (rows + 1) * pad), (255, 255, 255))
              d = ImageDraw.Draw(grid)
              try:
                  font = ImageFont.load_default(14)
              except Exception:
                  font = ImageFont.load_default()
              for i, (nm, cut) in enumerate(cuts):
                  r, c = divmod(i, cols)
                  x, y = pad + c * (cell + pad), pad + r * (cell + lab + pad)
                  ch = checker(cell, cell); ch.paste(cut.convert("RGBA").resize((cell, cell)), (0, 0), cut.convert("RGBA").resize((cell, cell)))
                  grid.paste(ch, (x, y)); d.text((x + 4, y + cell + 3), nm, fill=(20, 20, 20), font=font)
              sheet = os.path.join(args.out, "00_contact_sheet.png")
              grid.save(sheet)
              log(f"  contact sheet → {sheet}")
      
          status = EXIT_FAIL if failed else EXIT_OK
          if args.json:
              env = {"ok": status == EXIT_OK, "data": results,
                     "summary": {"total": len(files), "failed": failed}}
              if status != EXIT_OK:
                  env["error"] = {"code": "cutout_failed", "message": f"{failed} image(s) failed or collapsed"}
              print(json.dumps(env))
          else:
              for r in results:
                  print(f"{r['name']}\t{r.get('output', r.get('input',''))}\t{r['method']}\t{r['coverage']}\t{'ok' if r['ok'] else 'FAIL'}")
          return status
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • run.sh 4 KB
      #!/usr/bin/env bash
      # Self-test for rembg-ops/scripts/cutout.py.
      #
      # Offline-deterministic: exercises ONLY the colour-key path + post-processors
      # (pure Pillow/numpy), never the rembg ML path — so it needs no ~170 MB model
      # download and no network. Synthesises a flat-bg fixture, asserts the documented
      # exit codes and that the cut-out is RGBA with a transparent border + opaque
      # centre. Skips (exit 0) if Pillow/numpy aren't importable on this platform.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass (or platform-skip), 1 one or more failures
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      CUT="$SKILL/scripts/cutout.py"
      
      # Pick a python that actually runs (skip the Windows Store stub).
      PYTHON=""
      for c in python python3 py; do
        if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
      done
      [[ -z "$PYTHON" ]] && { echo "SKIP: no working python found" >&2; exit 0; }
      if ! "$PYTHON" -c "import PIL, numpy" >/dev/null 2>&1; then
        echo "SKIP: Pillow/numpy not installed — imaging tests skipped" >&2; exit 0
      fi
      
      SB="$("$PYTHON" -c 'import tempfile;print(tempfile.mkdtemp())')"
      trap '"$PYTHON" -c "import shutil,sys;shutil.rmtree(sys.argv[1],ignore_errors=True)" "$SB"' EXIT
      
      PASS=0; FAIL=0
      ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
      expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
      
      echo "=== rembg-ops self-test ==="
      
      # fixture: red square with black outline on a flat candy-yellow background
      "$PYTHON" - "$SB" <<'PY'
      import sys
      from PIL import Image, ImageDraw
      sb = sys.argv[1]
      im = Image.new("RGB", (256, 256), (255, 219, 51))
      d = ImageDraw.Draw(im)
      d.rectangle([70, 70, 186, 186], fill=(230, 57, 70), outline=(0, 0, 0), width=6)
      im.save(sb + "/fix.png")
      PY
      
      # ── CLI contract ────────────────────────────────────────────────────────────
      "$PYTHON" "$CUT" --help >/dev/null 2>&1; expect_exit "--help exits 0" 0 $?
      "$PYTHON" "$CUT" >/dev/null 2>&1;         expect_exit "no args → usage" 2 $?
      "$PYTHON" "$CUT" "$SB/nope.png" --out "$SB/o" >/dev/null 2>&1; expect_exit "missing input" 3 $?
      
      hlp="$("$PYTHON" "$CUT" --help 2>/dev/null)"; expect_has "--help lists EXAMPLES" "Examples:" "$hlp"
      
      # ── colour-key happy path (offline) ─────────────────────────────────────────
      "$PYTHON" "$CUT" "$SB/fix.png" --method colorkey --out "$SB/o" >/dev/null 2>&1
      expect_exit "colorkey exits 0" 0 $?
      
      "$PYTHON" - "$SB/o/fix.png" <<'PY'; expect_exit "cutout is RGBA, transparent border, opaque centre" 0 $?
      import sys
      import numpy as np
      from PIL import Image
      im = Image.open(sys.argv[1])
      a = np.array(im.convert("RGBA"))[:, :, 3]
      ok = im.mode == "RGBA" and a[0, 0] == 0 and a[128, 128] == 255 and 0.12 < (a > 16).mean() < 0.30
      sys.exit(0 if ok else 1)
      PY
      
      # ── --json envelope ─────────────────────────────────────────────────────────
      js="$("$PYTHON" "$CUT" "$SB/fix.png" --method colorkey --out "$SB/o2" --json 2>/dev/null)"
      expect_has "--json envelope ok:true" '"ok": true' "$js"
      echo "$js" | "$PYTHON" -c "import json,sys;json.load(sys.stdin)" && ok "--json is valid JSON" || no "--json is valid JSON"
      
      # ── post-processors run without error ───────────────────────────────────────
      "$PYTHON" "$CUT" "$SB/fix.png" --method colorkey --flatten-alpha 170 --out "$SB/o3" >/dev/null 2>&1
      expect_exit "--flatten-alpha runs" 0 $?
      "$PYTHON" "$CUT" "$SB/fix.png" --method colorkey --strip-offset-shadow --out "$SB/o4" >/dev/null 2>&1
      expect_exit "--strip-offset-shadow runs" 0 $?
      
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]]
      
  • SKILL.md 5.1 KB
    ---
    name: rembg-ops
    description: "Transparent-PNG cutouts / background removal for flat illustration, sticker, cartoon and avatar art — rembg (isnet-anime) with a deterministic fallback ladder for the cases ML drops. Triggers on: rembg, remove background, background removal, transparent PNG, cutout, die-cut sticker, isnet-anime, alpha matte, chroma/colour key, remove drop shadow, avatar cutout, flat illustration cutout."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: color-ops, svg-brand-tint-ops
    ---
    
    # rembg Operations — cutouts for flat illustration & sticker art
    
    Turn opaque, flat-background illustration (avatars, stickers, cartoon/vector art,
    generated character portraits) into clean **transparent PNGs** — without
    regenerating the source. rembg's ML models are the default engine; the value this
    skill adds is the **deterministic fallback ladder** for the cases pure ML gets
    wrong on flat-colour art.
    
    ## The one thing to know
    
    **rembg is a semantic model, not a chroma keyer.** `isnet-anime` (the model tuned
    for outlined cartoon art) segments *the salient character* and ignores background
    *colour* — so it cannot be "helped" with a greenscreen, and it fails in two
    predictable ways on flat art:
    
    1. **Pale / low-salience subjects get dropped** (a near-white moon, a mint UFO on a
       light background read as "not the subject").
    2. **Baked drop-shadows** survive as a grey ghost or an opaque offset blob.
    
    Both are fixed deterministically because flat sticker art always has **one flat
    background colour + a sealing outline**. That geometry makes a corner flood-fill
    colour-key exact, regardless of how pale the subject is. **The axis that matters is
    shadow-vs-no-shadow, never greenscreen-vs-not.**
    
    ## Quickstart
    
    ```bash
    # default: rembg isnet-anime, auto-fallback to colour-key if the subject collapses
    python scripts/cutout.py "avatars/*.png" --out cutouts/ --contact-sheet
    ```
    
    The `--contact-sheet` renders every cutout on a checkerboard so bad mattes jump
    out. Then triage failures with the ladder below. `--json | jq '.data[] |
    select(.coverage < 0.12)'` flags collapsed subjects programmatically.
    
    ## Method ladder — escalate only for the images that need it
    
    | Symptom on the contact sheet | Fix | Flag |
    |---|---|---|
    | Clean die-cut | — (rembg isnet-anime) | default |
    | Subject gone / faint ghost (pale subject) | flat-bg colour-key | `--method colorkey` |
    | Translucent subject reads see-through (glass, clear cup) | colour-key forces the sealed interior opaque | `--method colorkey` |
    | Semi-transparent grey shadow ghost | binarise alpha — drops the ~50% shadow, keeps the 100% subject | `--flatten-alpha 170` |
    | Opaque baked offset shadow (survives binarise) | colour-aware: drop dark pixels not hugging a coloured fill | `--strip-offset-shadow` |
    
    `auto` (the default) already does rembg→colour-key when the silhouette collapses
    (`--min-cov`, default 0.10). The post-processing flags are opt-in per image because
    detecting a shadow ghost automatically is unreliable — eyeball the sheet, escalate
    the few that need it. See [references/cutout-methods.md](references/cutout-methods.md)
    for how each method works and why.
    
    ## Why no regeneration
    
    Every method here **alpha-masks the original pixels** — it never re-renders the
    subject. That's what lets you cut out AI-generated character art (or any
    hand-drawn asset) without changing how the character looks. If your generator is
    text-to-image only (no img2img / transparent-output flag, e.g. most portrait
    CLIs), local background removal is the *only* way to get transparency without a
    character-drifting re-roll.
    
    ## The intended downstream shape
    
    Cut once to transparent PNG, then composite in the app: **transparent PNG over a
    CSS background colour + a CSS hard-offset shadow** (drawn in CSS, not baked into
    the image). This is what makes a colour-picker possible — one asset, any
    background. Never bake the shadow back into the PNG.
    
    ## Gotchas
    
    - **`isnet-anime` vs `u2net`:** the default `u2net` model leaves a grey ghost of a
      baked shadow and softer edges on cartoon art — always pass `--model isnet-anime`
      (this skill's default) for flat/outlined work. `u2net` is fine for photos.
    - **First rembg run downloads a ~170 MB model** (cached after). Fully offline work
      → `--method colorkey`.
    - **Colour-key keeps bg-coloured regions *inside* the subject** (they don't touch
      a border) — a subject with a same-as-background patch stays intact. It only
      removes background reachable from the image edge.
    - **Disconnected decorations** (sparkle stars floating on the background) are kept
      by colour-key as islands — usually what you want for a sticker; crop them out
      first if not.
    - **`--strip-offset-shadow` assumes a dark shadow + coloured subject fills.** On an
      all-dark subject it will over-eat; use `--flatten-alpha` there instead.
    
    ## Dependencies
    
    `pillow` + `numpy` (required); `scipy` (optional, faster flood-fill); `rembg`
    (only for the `rembg`/`auto` ML path — `colorkey` and the post-processors are
    pure Pillow/numpy and need no model download).
    
    ```bash
    uv pip install pillow numpy scipy rembg   # rembg optional
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related