odysseus-dev/odysseus · error · HTTPException

Invalid mask image: {e}

Error message

Invalid mask image: {e}

What it means

Raised by _write_bridge_mask in scripts/mlx_image_server.py when PIL cannot decode the uploaded mask, cannot extract the alpha channel, or cannot save the normalized binary mask PNG; the chained exception text is embedded in an HTTP 400. RGBA masks are binarized via alpha (<128 -> 255), other modes are converted to grayscale.

Source

Thrown at scripts/mlx_image_server.py:202


def _write_bridge_mask(raw: bytes, out_path: Path) -> None:
    try:
        from PIL import Image
        import io
    except Exception as e:
        raise HTTPException(503, "Pillow is required for MLX image edit bridge masks.") from e
    try:
        img = Image.open(io.BytesIO(raw))
        if img.mode == "RGBA":
            # OpenAI edits mask convention: transparent = regenerate.
            alpha = img.getchannel("A")
            mask = alpha.point(lambda p: 255 if p < 128 else 0)
        else:
            mask = img.convert("L")
        mask.save(out_path, format="PNG")
    except Exception as e:
        raise HTTPException(400, f"Invalid mask image: {e}") from e


def _run_bridge(cmd: list[str]) -> None:
    env = os.environ.copy()
    proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "MLX Swift bridge failed").strip()
        logger.error("MLX Swift bridge failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
        raise HTTPException(500, detail[-4000:])


def _run_ddcolor_bridge(model: str, image_raw: bytes, out_path: Path) -> None:
    bridge = _resolve_bridge(["odysseus-mlx-colorize", "mlx-ddcolor-serve"])
    if not bridge:
        raise _unsupported_swift_mlx_runtime(model)
    with tempfile.TemporaryDirectory(prefix="odysseus-ddcolor-") as td:
        inp = Path(td) / "input.png"
        _write_bridge_input_image(image_raw, inp)

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Verify locally: PIL can open the mask and it is grayscale or RGBA; re-save as PNG before upload
  2. Ensure the multipart field is named 'mask' and contains binary image data
  3. Send an RGBA PNG where transparent pixels mark the region to regenerate (OpenAI convention), or a plain L-mode mask
  4. If no mask is needed, omit it for DDColor, but note LaMa/MI-GAN requires one (error 948)

Example fix

# before
files = {'image': img, 'mask': io.StringIO('mask data')}
# after
files = {'image': open('img.png','rb'), 'mask': open('mask.png','rb')}  # real PNG mask
Defensive patterns

Strategy: validation

Validate before calling

from PIL import Image
m = Image.open(io.BytesIO(mask_bytes))
assert m.mode in ('L','RGBA','RGB'), m.mode
Image.open(io.BytesIO(mask_bytes)).verify()
Image.frombytes(m.mode, m.size, m.tobytes()).save(io.BytesIO(), format='PNG')

Prevention

When it happens

Trigger: Uploading a mask that is not a decodable image: corrupt bytes, empty upload, wrong field, or an RGBA file whose processing raises (e.g. truncated data stream detected only at getchannel/save time).

Common situations: Client sends the mask as raw base64 text instead of binary in multipart; mask exported from an editor with an unusual bit depth or ICC profile that PIL rejects; field name mismatch sends prompt text as the mask.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/5ca84c0cc97dd9a6. Report an issue: GitHub.