Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta

- **Citation:** Zenodo concept DOI `10.5281/zenodo.21552745` in `CITATION.cff`, README badge/Citing section, and `pyproject.toml` project URL (#269).


### Changed

- **`creative/bg_remover`**: Hardened the skill by reusing rembg sessions across executions, validating Base64 input and image files, rejecting empty, oversized (>25 MB), or invalid images, and automatically creating parent directories for `output_path`. Updated documentation and expanded bundle tests for the new validation behavior.

## [0.4.7] - 2026-07-25

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/skills/bg_remover.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Agent routing details live in `skills/creative/bg_remover/instructions.md`.
| `height` | Output image height in pixels |
| `model_used` | rembg model used for processing |
| `error` | Human-readable error message when `success` is `false` |
| `error_code` | `INVALID_INPUT`, `MISSING_DEPENDENCY`, or `PROCESSING_FAILED` |
| `error_code` | `INVALID_INPUT`, `FILE_NOT_FOUND`, `MISSING_DEPENDENCY`, or `PROCESSING_FAILED` |

## Input scenarios

Expand Down Expand Up @@ -123,7 +123,7 @@ Skill instructions: when to invoke, input/output conventions, URL and cloud pre-

### The Body (`skill.py`)

Lazy-imports `rembg` and Pillow, runs `new_session` + `remove`, and returns structured JSON with transparent PNG bytes.
Lazy-imports `rembg` and Pillow, reuses cached rembg sessions, runs `remove`, and returns structured JSON with transparent PNG bytes.

## Usage Examples

Expand All @@ -133,7 +133,7 @@ Use `bundle["class"]()` in the snippets below; explicit `bundle["module"].ClassN

### Runnable examples

See [examples/README.md](../../examples/README.md) for the current runnable-script inventory. There is no dedicated runnable example for this skill yet; the Claude, OpenAI, and DeepSeek sections below are **catalog snippets only** (same pattern as [PDF Form Filler](pdf_form_filler.md)).
See [examples/README.md](../../examples/README.md) for the current runnable-script inventory. See `examples/bg_remover_demo.py` for a runnable local example. The Claude, OpenAI, and DeepSeek sections below remain catalog snippets showing provider integration patterns.

Sample user request:

Expand Down
2 changes: 1 addition & 1 deletion docs/usage/agent_loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ skills in one harness.
| `office/pdf_form_filler` | - | `gemini_pdf_form_filler.py` | `claude_pdf_form_filler.py` | (catalog page) | (catalog page) | `ollama_skills_test.py` (multi-skill) |
| `compliance/mica_module` | - | `mica_rag_flow.py` | `mica_claude_flow.py` | (catalog page) | (catalog page) | `mica_ollama_flow.py` |
| `compliance/pii_masker` | `pii_guardrail_flow.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) |
| `creative/bg_remover` | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) |
| `creative/bg_remover` | `bg_remover_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) |
| `optimization/prompt_rewriter` | `prompt_compression_demo.py` (local execute) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | `ollama_skills_test.py` (multi-skill) |
| `data_engineering/synthetic_generator` | `build_dataset_demo.py` (local execute, Gemini backend) | (catalog page) | (catalog page) | (catalog page) | (catalog page) | (catalog page) |
| `data_engineering/novelty_extractor` | `novelty_extractor_demo.py` (local execute) | `gemini_novelty_extractor.py` | (catalog page) | (catalog page) | (catalog page) | `ollama_novelty_extractor.py` |
Expand Down
146 changes: 68 additions & 78 deletions examples/README.md

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions examples/bg_remover_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from pathlib import Path

from skillware.core.loader import SkillLoader


def run_demo():
print("Loading Background Remover...")

skill_bundle = SkillLoader.load_skill("creative/bg_remover")
skill_instance = skill_bundle["class"]()

input_path = "examples/sample_input.png"
output_path = "examples/sample_output_no_bg.png"

if not Path(input_path).exists():
print(f"Input image not found: {input_path}")
print("Place a sample PNG at the above path before running this demo.")
return

print(f"Removing background from: {input_path}")

result = skill_instance.execute(
{
"input_path": input_path,
"output_path": output_path,
}
)

if result["success"]:
print("Background removed successfully!")
print(f"Saved to: {result['output_path']}")
print(f"Output size: {result['width']} x {result['height']}")
print(f"Model: {result['model_used']}")
print(f"Base64 length: {len(result['image_base64'])}")
else:
print(f"Failed: {result['error']}")
print(f"Error code: {result['error_code']}")


if __name__ == "__main__":
run_demo()
18 changes: 17 additions & 1 deletion skills/creative/bg_remover/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ For S3, GCS, Azure Blob, or Cloudflare R2: download the object to a temp file

On the **first run** in a fresh environment, processing may take a few minutes while rembg downloads the ONNX model (~176 MB for `isnet-general-use`). Later runs reuse the cache and are much faster.

Background removal sessions are also reused across executions for the same model, reducing repeated initialization overhead.

## Input (one required: `image` OR `input_path`)

| Scenario | Parameter |
Expand All @@ -43,6 +45,15 @@ On the **first run** in a fresh environment, processing may take a few minutes w

If **both** `image` and `input_path` are sent, **`image` wins** (do not double-submit).

### Input validation

- Invalid Base64 payloads are rejected.
- `input_path` must reference an existing file (directories are rejected).
- Empty image files are rejected.
- Images larger than **25 MB** are rejected.
- Image integrity is validated before processing.
- output_path must not contain path traversal components (for example `..`).

### Example payloads

Chat / attachment:
Expand Down Expand Up @@ -91,6 +102,8 @@ Omit `model` for default `isnet-general-use`. Set `alpha_matting` only when edge
| Chat or API only | Omit `output_path`; use `image_base64` from the result (always present on success). |
| Save next to original | Same directory, new name (e.g. `1223_no_bg.png`). |

If the parent directory of `output_path` does not exist, it is created automatically before writing the PNG.

## Interpreting a successful result

When `success` is `true`:
Expand All @@ -109,10 +122,13 @@ Runtime: `rembg`, `pillow`, `onnxruntime`. Install: `pip install "skillware[crea

First `execute()` downloads the ONNX model to the rembg cache (`~/.u2net/` on Linux/macOS, `%USERPROFILE%\.u2net\` on Windows).

Subsequent executions reuse cached rembg sessions for the selected model, reducing repeated initialization overhead.

## Errors

| `error_code` | Response |
| :--- | :--- |
| `INVALID_INPUT` | Ask for an upload, attachment, base64 payload, or local `input_path`. |
| `INVALID_INPUT` | Invalid Base64, missing input, directory path, empty image, oversized image (>25 MB), corrupt image, or unsupported input. |
| `MISSING_DEPENDENCY` | Ask the user to run `pip install "skillware[creative_bg_remover]"` (or `pip install rembg pillow onnxruntime`), then retry. |
| `PROCESSING_FAILED` | Surface the `error` string; input may be missing, corrupt, or unsupported. |
| `FILE_NOT_FOUND` | The supplied `input_path` does not exist. Ask the user to verify the path and try again. |
4 changes: 2 additions & 2 deletions skills/creative/bg_remover/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "creative/bg_remover"
version: "0.1.0"
version: "0.2.0"
description: "Remove image backgrounds locally using rembg."
short_description: "Offline background removal using rembg with transparent PNG output."

Expand Down Expand Up @@ -77,7 +77,7 @@ outputs:

error_code:
type: string
description: "INVALID_INPUT, MISSING_DEPENDENCY, or PROCESSING_FAILED."
description: "INVALID_INPUT, FILE_NOT_FOUND, MISSING_DEPENDENCY, or PROCESSING_FAILED."

requirements:
- rembg>=2.0.0
Expand Down
95 changes: 89 additions & 6 deletions skills/creative/bg_remover/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,38 @@

from skillware.core.base_skill import BaseSkill

MAX_IMAGE_BYTES = 25 * 1024 * 1024 # 25 MB


class BackgroundRemover(BaseSkill):
"""Remove image backgrounds locally using rembg."""

_sessions = {}

@classmethod
def _get_session(cls, model: str):
"""Load and reuse rembg sessions across executions."""
if model not in cls._sessions:
from rembg import new_session

cls._sessions[model] = new_session(model)

return cls._sessions[model]

@staticmethod
def _validate_output_path(output_path: str) -> Path:
output = Path(output_path)

if ".." in output.parts:
raise ValueError("Unsafe output_path contains path traversal.")

return output

@property
def manifest(self) -> Dict[str, Any]:
return {
"name": "creative/bg_remover",
"version": "0.1.0",
"version": "0.2.0",
"description": (
"Remove image backgrounds locally using rembg "
"and return a transparent PNG."
Expand All @@ -26,7 +49,7 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
try:
try:
from PIL import Image
from rembg import new_session, remove
from rembg import remove
except ImportError:
return {
"success": False,
Expand All @@ -53,11 +76,61 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:

# Read image bytes
if image_b64:
image_bytes = base64.b64decode(image_b64)
try:
image_bytes = base64.b64decode(image_b64, validate=True)
except Exception:
return {
"success": False,
"error": "Invalid base64 image.",
"error_code": "INVALID_INPUT",
}
else:
image_bytes = Path(input_path).read_bytes()
input_file = Path(input_path)

if input_file.is_dir():
return {
"success": False,
"error": "Input path must be a file, not a directory.",
"error_code": "INVALID_INPUT",
}

if not input_file.exists():
return {
"success": False,
"error": f"Input file '{input_path}' was not found.",
"error_code": "FILE_NOT_FOUND",
}

image_bytes = input_file.read_bytes()

if len(image_bytes) > MAX_IMAGE_BYTES:
return {
"success": False,
"error": f"Input image exceeds the maximum size of {MAX_IMAGE_BYTES // (1024 * 1024)} MB.",
"error_code": "INVALID_INPUT",
}

if len(image_bytes) == 0:
return {
"success": False,
"error": "Input image is empty.",
"error_code": "INVALID_INPUT",
}

try:
image = Image.open(io.BytesIO(image_bytes))
image.verify()

# Reopen after verify() because verify() leaves the image unusable.
image = Image.open(io.BytesIO(image_bytes))
except Exception:
return {
"success": False,
"error": "Input is not a valid image.",
"error_code": "INVALID_INPUT",
}

session = new_session(model)
session = self._get_session(model)

output_bytes = remove(
image_bytes,
Expand All @@ -76,7 +149,17 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:

# Optional save
if output_path:
Path(output_path).write_bytes(buffer.getvalue())
try:
output_file = self._validate_output_path(output_path)
except ValueError:
return {
"success": False,
"error": "Unsafe output_path.",
"error_code": "INVALID_INPUT",
}

output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_bytes(buffer.getvalue())

return {
"success": True,
Expand Down
Loading