diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eef7b8..b6e203f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Added +- Added `creative/bg_remover`, an offline background removal skill powered by rembg. + +### Changed + +- **`creative/bg_remover`**: Hardened the skill with cached rembg sessions, improved input and path validation, automatic output directory creation, and input size limits. + +- **Documentation**: README Stats section and live PyPI download badges (pepy / PyPI Stats dashboards, header `DLs ↓` total). + +### Added + - **Tests**: `tests/test_registry_docs.py` — CI doc-drift guards that verify skill catalog, examples index, and agent-loops reference matrix stay in sync with the registry (#183). - **Documentation**: Cross-linked `tests/test_registry_docs.py` in `ai_native_workflow.md` and `CONTRIBUTING.md` so contributors know doc-drift guards run in CI; added explicit PR checklist reminder in `CONTRIBUTING.md` (#193). diff --git a/docs/skills/README.md b/docs/skills/README.md index 8246f24..97aeb75 100644 --- a/docs/skills/README.md +++ b/docs/skills/README.md @@ -2,7 +2,7 @@ Welcome to the official catalog of Skillware capabilities. New here? Start with the [project README](../../README.md). -Browse by category below, or run `skillware list` after `pip install skillware` to see locally available skills. +Browse by category below, or run `skillware list` after `pip install skillware` to see locally available skills. When contributing a new skill, see [Choosing a category](../../CONTRIBUTING.md#choosing-a-category) in CONTRIBUTING.md. ## Office Skills for document processing, email automation, and productivity. @@ -11,12 +11,20 @@ Skills for document processing, email automation, and productivity. | :--- | :--- | :--- | :--- | | **[PDF Form Filler](pdf_form_filler.md)** | `office/pdf_form_filler` | [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) | Fills AcroForm-based PDFs by mapping user instructions to detected form fields using LLM-based semantic understanding. | +## Creative +Skills for image processing, media editing, and creative utilities. + +| Skill | ID | Issuer | Description | +| :--- | :--- | :--- | :--- | +| **[Background Remover](bg_remover.md)** | `creative/bg_remover` | [@AyushSrivastava1818](https://github.com/AyushSrivastava1818) | Removes image backgrounds locally using rembg and returns transparent PNGs. | + ## Finance Tools for financial analysis, blockchain interaction, and regulatory compliance. | Skill | ID | Issuer | Description | | :--- | :--- | :--- | :--- | | **[Wallet Screening](wallet_screening.md)** | `finance/wallet_screening` | [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) | Comprehensive risk assessment for Ethereum wallets. Checks sanctions lists (OFAC, FBI) and identifies interactions with malicious contracts (Mixers, Scams). | +| **[UK Companies House Handler](uk_companies_house_handler.md)** | `finance/uk_companies_house_handler` | [@Areen-09](https://github.com/Areen-09) ([@ARPAHLS](https://github.com/ARPAHLS)) | Deterministic UK Companies House API handler: company search, officers, PSC, filing history, and UK terminology mapping via structured actions. | ## DeFi On-chain execution and trading for dedicated agent wallets (structured intent, previews, confirmations). @@ -56,6 +64,13 @@ Skills that assist developers in understanding codebases, planning changes, and | :--- | :--- | :--- | :--- | | **[Issue Resolver](issue_resolver.md)** | `dev_tools/issue_resolver` | [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) | GitHub issue URL prep, nine-stage agent workflow, conditional verify/commit gates, and commit-message validation. | +## Monitoring +Observability and guardrails for long-running autonomous agent loops. + +| Skill | ID | Issuer | Description | +| :--- | :--- | :--- | :--- | +| **[Token Limiter](token_limiter.md)** | `monitoring/token_limiter` | [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) | Deterministic token budget gate that returns CONTINUE, WARN, or FORCE_TERMINATE for host loops. | + ## Wellness Supportive coaching guardrails, crisis triage, and grounded psychoeducation for host agents. @@ -73,7 +88,9 @@ Registry skills live under `skills///` in the repository a from skillware.core.loader import SkillLoader # Load by registry ID (category/skill_name) -skill = SkillLoader.load_skill("finance/wallet_screening") +bundle = SkillLoader.load_skill("finance/wallet_screening") +skill = bundle["class"]() +# Or: skill = bundle["module"].WalletScreeningSkill() ``` --- diff --git a/docs/skills/bg_remover.md b/docs/skills/bg_remover.md new file mode 100644 index 0000000..ab72fd2 --- /dev/null +++ b/docs/skills/bg_remover.md @@ -0,0 +1,367 @@ +# Background Remover + +**Domain:** `creative` +**Skill ID:** `creative/bg_remover` +**Issuer:** [@AyushSrivastava1818](https://github.com/AyushSrivastava1818) + +[Skill Library](README.md) · [Testing](../TESTING.md) + +## Overview + +`creative/bg_remover` is a deterministic image-processing skill that removes the background from still images locally using `rembg`. It accepts Base64 image data or local image paths and produces transparent PNG output without requiring cloud services after the initial ONNX model download. + +## Capabilities + +- Removes image backgrounds locally using `rembg` +- Accepts Base64 images or local file paths +- Validates Base64, file paths, image contents, and input size +- Reuses cached inference sessions for improved performance +- Produces transparent PNG output +- Supports optional `alpha_matting` +- Works completely offline after the initial ONNX model download + +## Integration Guide + +### Environment + +This skill does not require API keys or cloud credentials. + +Install runtime dependencies: + +```bash +pip install rembg pillow onnxruntime + +The first execution downloads the selected ONNX model (approximately 176 MB). Once cached, subsequent executions are fully offline. + +Provider API keys are only required when using the provider integration examples below. + +``` + +## Input Parameters + +| Parameter | Required | Description | +| :--- | :---: | :--- | +| `image` | No | Base64-encoded input image | +| `input_path` | No | Local input image path | +| `output_path` | No | Local output PNG path | +| `model` | No | Optional rembg model | + +| `alpha_matting` | No | Enable alpha matting if supported | + +## Output Schema + +| Field | Description | +| :--- | :--- | +| `success` | Indicates whether processing completed successfully | +| `image_base64` | Base64-encoded PNG when `output_path` is not provided | +| `mime_type` | Output MIME type (`image/png`) | +| `width` | Output image width | +| `height` | Output image height | +| `model_used` | rembg model used for processing | +| `output_path` | Output file path when provided | +| `error_code` | Structured error code for failed requests | + +## Input and Output Recipes + +### Base64 input + +```json +{ + "image": "" +} +``` + +### Local file input + +```json +{ + "input_path": "input.png" +} +``` + +### Save output locally + +```json +{ + "input_path": "input.png", + "output_path": "output.png" +} +``` + +## Data Schema + +```json +{ + "success": true, + "image_base64": "", + "mime_type": "image/png", + "width": 1024, + "height": 768, + "model_used": "u2net" +} +``` + +## Internal Architecture + +The skill lives in `skills/creative/bg_remover/`. + +### The Mind (`instructions.md`) + +Guides the host agent on when to invoke the skill, accepted inputs, and expected output. + +### The Body (`skill.py`) + +Processes still images locally using `rembg`, validates inputs, reuses cached inference sessions, supports Base64 and file-based workflows, and returns transparent PNG output. + +## Cloud Storage Recipes + +### AWS S3 + +Download the source image from S3 to a local temporary file, execute the skill using `input_path`, then upload the generated PNG from `output_path` back to S3. + +```json +{ + "input_path": "/tmp/input.png", + "output_path": "/tmp/output.png" +} +``` + +### Google Cloud Storage + +Download the object from GCS, process it locally with `input_path`, then upload the generated PNG. + +```json +{ + "input_path": "/tmp/input.png", + "output_path": "/tmp/output.png" +} +``` + +### Azure Blob Storage + +Download the blob to local storage before invoking the skill and upload the generated PNG afterwards. + +```json +{ + "input_path": "/tmp/input.png", + "output_path": "/tmp/output.png" +} +``` + +### Cloudflare R2 + +Download the object locally, invoke the skill, then upload the resulting transparent PNG back to the bucket. + +```json +{ + "input_path": "/tmp/input.png", + "output_path": "/tmp/output.png" +} +``` + +## Notes + +- Runs completely offline after the required model is available. +- The first execution downloads the required ONNX model (approximately 176 MB). Later executions reuse the cached model and work completely offline. +- Input validation rejects invalid Base64, missing files, empty files, corrupt images, directory paths, and inputs larger than 25 MB. +- Parent directories for `output_path` are created automatically when needed. +- Unit tests mock `rembg` and do not download ONNX models. +- The optional `model` and `alpha_matting` parameters are forwarded to `rembg` when supported by the installed version. + +## Usage Examples + +Guides: [Usage index](../usage/README.md) · [Agent loops](../usage/agent_loops.md) · [API keys](../usage/api_keys.md) + +Use `bundle["class"]()` in the snippets below. + +### Runnable examples + +See [examples/README.md](../../examples/README.md) for the current runnable provider examples. + +Sample user request: + +> Remove the background from `product.png` and save the transparent PNG. + +### Direct execute + +```python +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +result = skill.execute({ + "input_path": "product.png", + "output_path": "product_no_bg.png", +}) +``` + +print(result) + +### Gemini + +```python +import google.genai as genai +from google.genai import types + +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +client = genai.Client() +tool = SkillLoader.to_gemini_tool(bundle) +tool_name = SkillLoader._sanitize_gemini_tool_name( + bundle["manifest"]["name"] +) + +response = client.models.generate_content( + model="gemini-2.5-flash", + contents=( + "Remove the background from product.png and save the result " + "as product_no_bg.png." + ), + config=types.GenerateContentConfig( + tools=[tool], + system_instruction=bundle["instructions"], + ), +) + +for part in response.candidates[0].content.parts: + if part.function_call and part.function_call.name == tool_name: + result = skill.execute(dict(part.function_call.args)) + + follow_up = client.models.generate_content( + model="gemini-2.5-flash", + contents=[ + "Use this tool result to answer the original request.", + { + "function_response": { + "name": part.function_call.name, + "response": {"result": result}, + } + }, + ], + config=types.GenerateContentConfig( + tools=[tool], + system_instruction=bundle["instructions"], + ), + ) + + print(follow_up.text) +``` + +### Claude + +```python +import os +import anthropic + +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +client = anthropic.Anthropic( + api_key=os.environ.get("ANTHROPIC_API_KEY"), +) + +tools = [SkillLoader.to_claude_tool(bundle)] + +# On tool_use: +# result = skill.execute(tool_use.input) +# Return the tool result to Claude. +``` + +### OpenAI + +```python +import os +from openai import OpenAI + +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + +tool = SkillLoader.to_openai_tool(bundle) + +# Match tool_call.function.name and execute: +# result = skill.execute(args) +``` + +### DeepSeek + +```python +import os +from openai import OpenAI + +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +client = OpenAI( + api_key=os.environ.get("DEEPSEEK_API_KEY"), + base_url="https://api.deepseek.com", +) + +tool = SkillLoader.to_deepseek_tool(bundle) + +# Match tool_call.function.name and execute: +# result = skill.execute(args) +``` + +### Ollama (prompt mode) + +```python +import json + +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("creative/bg_remover") +skill = bundle["class"]() + +prompt = SkillLoader.to_ollama_prompt(bundle) + +print(prompt) +print("User: Remove the background from product.png and save it as product_no_bg.png.") + +# When the model emits JSON tool arguments, +# pass them to execute(): + +result = skill.execute({ + "input_path": "product.png", + "output_path": "product_no_bg.png", +}) + +print(json.dumps(result, indent=2)) +``` + +## Limitations + +- Supports still images only. +- Video processing is not supported. +- Batch processing is not supported. +- The first execution downloads the selected ONNX model. +- Output quality depends on the selected `rembg` model. + +--- + +## Enterprise disclaimer + +This skill is provided for demonstration and integration purposes. It is intended as a starting point that you can adapt to your own workflows and image-processing requirements. For an enterprise-grade version of this skill with dedicated support, SLAs, and customization, contact skills@arpacorp.net. \ No newline at end of file diff --git a/skills/creative/__init__.py b/skills/creative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/creative/bg_remover/__init__.py b/skills/creative/bg_remover/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/creative/bg_remover/card.json b/skills/creative/bg_remover/card.json new file mode 100644 index 0000000..ba6d2ec --- /dev/null +++ b/skills/creative/bg_remover/card.json @@ -0,0 +1,18 @@ +{ + "name": "creative/bg_remover", + "description": "Remove image backgrounds locally using rembg.", + "category": "creative", + "tags": [ + "image", + "background", + "rembg", + "offline", + "png" + ], + "issuer": { + "name": "Ayush Srivastava", + "email": "ayush.sri0705@gmail.com", + "github": "AyushSrivastava1818", + "org": "" + } +} \ No newline at end of file diff --git a/skills/creative/bg_remover/instructions.md b/skills/creative/bg_remover/instructions.md new file mode 100644 index 0000000..5f574ba --- /dev/null +++ b/skills/creative/bg_remover/instructions.md @@ -0,0 +1,35 @@ +# Background Remover + +Use this skill whenever a user asks to: + +- remove the background from an image +- isolate an object or person +- create a transparent PNG +- cut out a product +- prepare an image for design or e-commerce workflows + +Do not use this skill for: + +- videos +- batch image processing +- cloud-only editing workflows + +Accepted inputs: + +- Base64 image data (`image`) +- Local file (`input_path`) + +Input validation includes: + +- Invalid Base64 detection +- Missing input files +- Empty input files +- Invalid or corrupt images +- Directory path rejection +- Maximum input size enforcement (25 MB) + +If `output_path` is supplied, parent directories are created automatically if needed, and the generated transparent PNG is saved there. + +Otherwise, the PNG is returned as a Base64 string. + +This skill processes images entirely locally using `rembg`, reuses cached inference sessions for improved performance, and always produces PNG output with transparency. \ No newline at end of file diff --git a/skills/creative/bg_remover/manifest.yaml b/skills/creative/bg_remover/manifest.yaml new file mode 100644 index 0000000..fb1992d --- /dev/null +++ b/skills/creative/bg_remover/manifest.yaml @@ -0,0 +1,78 @@ +name: "creative/bg_remover" +version: "0.2.0" +description: "Remove image backgrounds locally using rembg with cached inference sessions and robust input validation." +short_description: "Offline background removal using rembg with transparent PNG output." + +issuer: + name: Ayush Srivastava + email: "ayush.sri0705@gmail.com" + github: AyushSrivastava1818 + org: "" + +parameters: + type: object + properties: + image: + type: string + description: "Base64 encoded input image." + + input_path: + type: string + description: "Optional local input image." + + output_path: + type: string + description: "Optional output PNG path." + + model: + type: string + enum: + - isnet-general-use + - u2net + - u2net_human_seg + - silueta + default: isnet-general-use + + alpha_matting: + type: boolean + default: false + +outputs: + success: + type: boolean + + image_base64: + type: string + + mime_type: + type: string + + output_path: + type: string + + width: + type: integer + + height: + type: integer + + model_used: + type: string + + error: + type: string + + error_code: + type: string + +requirements: + - rembg>=2.0.0 + - pillow + - onnxruntime + +constitution: | + 1. IMAGE_ONLY: Process still images only. + 2. LOCAL_FIRST: Never use cloud APIs. + 3. PRIVACY: Never store image bytes. + 4. DETERMINISTIC: Same input produces same output. + 5. FAIL_CLOSED: Return structured errors on invalid input. \ No newline at end of file diff --git a/skills/creative/bg_remover/skill.py b/skills/creative/bg_remover/skill.py new file mode 100644 index 0000000..bf9cada --- /dev/null +++ b/skills/creative/bg_remover/skill.py @@ -0,0 +1,156 @@ +import base64 +import io +from pathlib import Path +from typing import Any, Dict + +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] + + @property + def manifest(self) -> Dict[str, Any]: + return { + "name": "creative/bg_remover", + "version": "0.2.0", + "description": ( + "Remove image backgrounds locally using rembg " + "and return a transparent PNG." + ), + } + + def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Execute background removal.""" + + try: + try: + from PIL import Image + from rembg import remove + except ImportError: + return { + "success": False, + "error": ( + "The 'rembg' dependency is not installed. " + "Install it with: pip install rembg pillow onnxruntime" + ), + "error_code": "MISSING_DEPENDENCY", + } + + image_b64 = params.get("image") + input_path = params.get("input_path") + output_path = params.get("output_path") + model = params.get("model", "isnet-general-use") + alpha_matting = params.get("alpha_matting", False) + + if not image_b64 and not input_path: + return { + "success": False, + "error": "Either image or input_path must be provided.", + "error_code": "INVALID_INPUT", + } + + # Read image bytes + if 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: + 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() + except Exception: + return { + "success": False, + "error": "Input is not a valid image.", + "error_code": "INVALID_INPUT", + } + + session = self._get_session(model) + + output_bytes = remove( + image_bytes, + session=session, + alpha_matting=alpha_matting, + ) + + # Read PNG + image = Image.open(io.BytesIO(output_bytes)) + + # Convert back to base64 + buffer = io.BytesIO() + image.save(buffer, format="PNG") + + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + + # Optional save + if output_path: + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_bytes(buffer.getvalue()) + + return { + "success": True, + "image_base64": encoded, + "mime_type": "image/png", + "output_path": output_path, + "width": image.width, + "height": image.height, + "model_used": model, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "error_code": "PROCESSING_FAILED", + } diff --git a/skills/creative/bg_remover/test_skill.py b/skills/creative/bg_remover/test_skill.py new file mode 100644 index 0000000..0797a90 --- /dev/null +++ b/skills/creative/bg_remover/test_skill.py @@ -0,0 +1,277 @@ +import base64 +import io +import os + +import pytest +import yaml +from PIL import Image + +from .skill import BackgroundRemover + +import sys +import types + + +@pytest.fixture(autouse=True) +def mock_remove(monkeypatch): + """Mock rembg so tests stay offline.""" + + def fake_remove(image_bytes, *args, **kwargs): + img = Image.new("RGBA", (100, 100), (255, 0, 0, 0)) + buffer = io.BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + def fake_new_session(model_name="u2net", *args, **kwargs): + return object() + + fake_module = types.SimpleNamespace( + remove=fake_remove, + new_session=fake_new_session, + ) + + monkeypatch.setitem(sys.modules, "rembg", fake_module) + + +@pytest.fixture +def skill(): + return BackgroundRemover() + + +@pytest.fixture +def manifest(): + manifest_path = os.path.join( + os.path.dirname(__file__), + "manifest.yaml", + ) + + with open(manifest_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def test_manifest(skill, manifest): + assert skill.manifest["name"] == manifest["name"] + assert skill.manifest["version"] == manifest["version"] + + +def test_missing_input(skill): + result = skill.execute({}) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" + + +def create_image(): + + image = Image.new("RGB", (64, 64), "white") + + buffer = io.BytesIO() + + image.save(buffer, format="PNG") + + return base64.b64encode(buffer.getvalue()).decode() + + +def test_base64_image(skill): + + img = create_image() + + result = skill.execute({"image": img}) + + assert result["success"] is True + assert result["mime_type"] == "image/png" + assert result["width"] > 0 + assert result["height"] > 0 + + +def test_output_keys(skill): + + img = create_image() + + result = skill.execute({"image": img}) + + expected = { + "success", + "image_base64", + "mime_type", + "output_path", + "width", + "height", + "model_used", + } + + assert expected.issubset(result.keys()) + + +def test_invalid_base64(skill): + + result = skill.execute({"image": "not_base64"}) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" + +def test_missing_dependency(monkeypatch, skill): + """Returns MISSING_DEPENDENCY when optional libraries are unavailable.""" + + monkeypatch.delitem(sys.modules, "rembg", raising=False) + monkeypatch.delitem(sys.modules, "PIL", raising=False) + monkeypatch.delitem(sys.modules, "PIL.Image", raising=False) + + original_import = __import__ + + def fake_import(name, *args, **kwargs): + if name in ("rembg", "PIL", "PIL.Image"): + raise ImportError + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + result = skill.execute({"image": create_image()}) + + assert result["success"] is False + assert result["error_code"] == "MISSING_DEPENDENCY" + +def test_input_path(skill, tmp_path): + image = Image.new("RGB", (64, 64), "white") + + input_file = tmp_path / "input.png" + image.save(input_file) + + result = skill.execute( + { + "input_path": str(input_file), + } + ) + + assert result["success"] is True + assert result["mime_type"] == "image/png" + + +def test_output_path(skill, tmp_path): + image = Image.new("RGB", (64, 64), "white") + + input_file = tmp_path / "input.png" + output_file = tmp_path / "output.png" + + image.save(input_file) + + result = skill.execute( + { + "input_path": str(input_file), + "output_path": str(output_file), + } + ) + + assert result["success"] is True + assert output_file.exists() + assert result["output_path"] == str(output_file) + +def test_session_reuse(monkeypatch, skill): + """Verify rembg sessions are reused for the same model.""" + + call_count = 0 + + def fake_new_session(model_name="u2net", *args, **kwargs): + nonlocal call_count + call_count += 1 + return object() + + def fake_remove(image_bytes, *args, **kwargs): + img = Image.new("RGBA", (100, 100), (255, 0, 0, 0)) + buffer = io.BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + fake_module = types.SimpleNamespace( + remove=fake_remove, + new_session=fake_new_session, + ) + + monkeypatch.setitem(sys.modules, "rembg", fake_module) + + # Reset cached sessions so the test starts clean + BackgroundRemover._sessions.clear() + + image = create_image() + + skill.execute({"image": image}) + skill.execute({"image": image}) + + assert call_count == 1 + +def test_missing_input_file(skill): + result = skill.execute( + { + "input_path": "does_not_exist.png", + } + ) + + assert result["success"] is False + assert result["error_code"] == "FILE_NOT_FOUND" + +def test_empty_input_file(skill, tmp_path): + empty_file = tmp_path / "empty.png" + empty_file.write_bytes(b"") + + result = skill.execute( + { + "input_path": str(empty_file), + } + ) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" + +def test_invalid_image(skill): + invalid_image = base64.b64encode( + b"this is not an image" + ).decode() + + result = skill.execute( + { + "image": invalid_image, + } + ) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" + +def test_input_path_is_directory(skill, tmp_path): + result = skill.execute( + { + "input_path": str(tmp_path), + } + ) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" + +def test_output_parent_created(skill, tmp_path): + image = Image.new("RGB", (64, 64), "white") + + input_file = tmp_path / "input.png" + image.save(input_file) + + output_file = tmp_path / "nested" / "folder" / "output.png" + + result = skill.execute( + { + "input_path": str(input_file), + "output_path": str(output_file), + } + ) + + assert result["success"] is True + assert output_file.exists() + +def test_image_too_large(skill): + large_image = base64.b64encode(b"x" * (26 * 1024 * 1024)).decode() + + result = skill.execute( + { + "image": large_image, + } + ) + + assert result["success"] is False + assert result["error_code"] == "INVALID_INPUT" \ No newline at end of file