Repository navigation
fix: full-resolution quality pipeline — preserve output dimensions and max quality #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
698766e
0192cfe
89ba276
732bd6f
5290c89
96853a0
020a8ae
18873bb
c5c4b16
76c2a17
be95a38
e960632
e36050c
eff24c2
5a53f84
8ece28e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| name: Build Windows Portable | ||
|
|
||
| on: | ||
| release: | ||
| types: [published] | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| build-windows: | ||
| runs-on: windows-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
|
|
||
| - name: Install dependencies | ||
| run: | | ||
| python -m pip install --upgrade pip | ||
| pip install pyinstaller | ||
| pip install -r requirements-core.txt | ||
| pip install pywebview[pyside6] psutil pyyaml | ||
|
|
||
| - name: Build portable exe | ||
| run: python build_exe.py | ||
|
|
||
| - name: Upload artifact | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: OpenWipe-portable-win64 | ||
| path: OpenWipe-portable-win64.zip | ||
|
|
||
| - name: Attach to release | ||
| if: github.event_name == 'release' | ||
| uses: softprops/action-gh-release@v2 | ||
| with: | ||
| files: OpenWipe-portable-win64.zip |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # OpenWipe — Full-Resolution Watermark & Object Removal | ||
|
|
||
| **Fully local. Full resolution. No cloud. No quality loss.** | ||
|
|
||
| A desktop application for removing watermarks, logos, text, and unwanted objects from images — powered by Florence-2 detection + LaMa inpainting, running entirely on your machine. | ||
|
|
||
| ## Why another watermark remover? | ||
|
|
||
| Commercial tools like VisioMint upload your images to the cloud for processing. Open-source alternatives often sacrifice quality by downscaling before processing. OpenWipe does neither: | ||
|
|
||
| - **100% local** — your images never leave your machine | ||
| - **Full resolution** — output matches input dimensions exactly | ||
| - **Max quality output** — JPEG quality=100 with 4:4:4 chroma, or lossless PNG | ||
| - **Smart processing** — automatic VRAM-aware tiling for large images | ||
|
|
||
| ## Quality improvements over WatermarkRemover-AI | ||
|
|
||
| This project fixes the core quality pipeline issues: | ||
|
|
||
| | Issue | WatermarkRemover-AI | OpenWipe | | ||
| |---|---|---| | ||
| | Resolution | Downscaled via `max_dim` | Full resolution by default | | ||
| | JPEG quality | PIL default (75) | 100 + 4:4:4 chroma | | ||
| | Large images | Downscaled | Full-res + smart cropping | | ||
| | Output dimensions | Reduced | Original preserved | | ||
|
|
||
| ## Features | ||
|
|
||
| - **Auto-detect watermarks/text/logos** using Florence-2 | ||
| - **AI-powered inpainting** using LaMa (Large Mask Inpainting) | ||
| - **Brush-select** any region for manual object removal | ||
| - **Batch processing** for folders of images | ||
| - **Video support** with frame-by-frame watermark removal | ||
| - **Stroke mode** for precise edge-level masks vs box masks | ||
| - **Double-pass** inpainting for cleaner results | ||
| - **Transparent mode** — make watermark regions transparent instead of inpainting | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| pip install -r requirements.txt | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| # Single image | ||
| python remwm.py input.jpg output.jpg | ||
|
|
||
| # Batch process a folder | ||
| python remwm.py ./photos/ ./clean/ | ||
|
|
||
| # With custom detection prompt | ||
| python remwm.py input.jpg output.jpg --detection-prompt "watermark logo" | ||
|
|
||
| # Preview detections without processing | ||
| python remwm.py input.jpg --preview | ||
|
|
||
| # Manual stroke mask (more precise than box) | ||
| python remwm.py input.jpg output.jpg --mask-mode stroke | ||
| ``` | ||
|
|
||
| ## Tech Stack | ||
|
|
||
| - **Florence-2** (Microsoft) — open-vocabulary object/text detection | ||
| - **LaMa** — Large Mask Inpainting (TorchScript) | ||
| - **PyTorch** — inference backend (CPU or CUDA) | ||
| - **PyWebview** — native desktop window | ||
| - **Alpine.js** — lightweight UI framework | ||
|
|
||
| ## License | ||
|
|
||
| MIT — see [LICENSE](LICENSE). | ||
|
|
||
| ## Credits | ||
|
|
||
| Built on [WatermarkRemover-AI](https://github.com/D-Ogi/WatermarkRemover-AI) by D-Ogi (MIT), with significant quality pipeline improvements. | ||
| LaMa inpainting adapted from [IOPaint](https://github.com/Sanster/IOPaint) (Apache-2.0). |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,224 @@ | ||||||||||||||||||||
| """OpenWipe API backend — serves ML inference to the Tauri frontend. | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Provides REST endpoints for: | ||||||||||||||||||||
| - Watermark/object detection (Florence-2) | ||||||||||||||||||||
| - Inpainting (LaMa) | ||||||||||||||||||||
| - File upload/download | ||||||||||||||||||||
| - Batch processing | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Runs on localhost:8765. Started automatically by the Tauri shell. | ||||||||||||||||||||
| """ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import io | ||||||||||||||||||||
| import os | ||||||||||||||||||||
| import sys | ||||||||||||||||||||
| import uuid | ||||||||||||||||||||
| import json | ||||||||||||||||||||
| import base64 | ||||||||||||||||||||
| import tempfile | ||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import numpy as np | ||||||||||||||||||||
| import cv2 | ||||||||||||||||||||
| from PIL import Image | ||||||||||||||||||||
| from loguru import logger | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Add parent dir to path for imports | ||||||||||||||||||||
| sys.path.insert(0, str(Path(__file__).parent.parent)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from fastapi import FastAPI, UploadFile, File, HTTPException | ||||||||||||||||||||
| from fastapi.middleware.cors import CORSMiddleware | ||||||||||||||||||||
| from fastapi.responses import FileResponse, JSONResponse | ||||||||||||||||||||
| from fastapi.staticfiles import StaticFiles | ||||||||||||||||||||
| from pydantic import BaseModel | ||||||||||||||||||||
| from typing import Optional | ||||||||||||||||||||
|
|
||||||||||||||||||||
| app = FastAPI(title="OpenWipe API", version="0.1.0") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| app.add_middleware( | ||||||||||||||||||||
| CORSMiddleware, | ||||||||||||||||||||
| allow_origins=["*"], | ||||||||||||||||||||
| allow_methods=["*"], | ||||||||||||||||||||
| allow_headers=["*"], | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Global model instances (lazy loaded) | ||||||||||||||||||||
| _florence_model = None | ||||||||||||||||||||
| _florence_processor = None | ||||||||||||||||||||
| _lama_model = None | ||||||||||||||||||||
| _device = None | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def get_device(): | ||||||||||||||||||||
| global _device | ||||||||||||||||||||
| if _device is None: | ||||||||||||||||||||
| import torch | ||||||||||||||||||||
| _device = "cuda" if torch.cuda.is_available() else "cpu" | ||||||||||||||||||||
| return _device | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def get_florence(): | ||||||||||||||||||||
| global _florence_model, _florence_processor | ||||||||||||||||||||
| if _florence_model is None: | ||||||||||||||||||||
| from transformers import AutoProcessor, Florence2ForConditionalGeneration | ||||||||||||||||||||
| from model_assets import ensure_florence | ||||||||||||||||||||
| device = get_device() | ||||||||||||||||||||
| path = str(ensure_florence()) | ||||||||||||||||||||
| dtype = None if device == "cuda" else __import__("torch").float32 | ||||||||||||||||||||
| _florence_model = Florence2ForConditionalGeneration.from_pretrained( | ||||||||||||||||||||
| path, torch_dtype=dtype | ||||||||||||||||||||
| ).to(device).eval() | ||||||||||||||||||||
| _florence_processor = AutoProcessor.from_pretrained(path) | ||||||||||||||||||||
|
Comment on lines
+68
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Publish Florence components only after both load. If Proposed fix--- "a/backend/api.py"
+++ "b/backend/api.py"
@@ -64,10 +64,11 @@
device = get_device()
path = str(ensure_florence())
dtype = None if device == "cuda" else __import__("torch").float32
- _florence_model = Florence2ForConditionalGeneration.from_pretrained(
+ model = Florence2ForConditionalGeneration.from_pretrained(
path, torch_dtype=dtype
).to(device).eval()
- _florence_processor = AutoProcessor.from_pretrained(path)
+ processor = AutoProcessor.from_pretrained(path)
+ _florence_model, _florence_processor = model, processor
logger.info("Florence-2 loaded")
return _florence_model, _florence_processor
📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| logger.info("Florence-2 loaded") | ||||||||||||||||||||
| return _florence_model, _florence_processor | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def get_lama(): | ||||||||||||||||||||
| global _lama_model | ||||||||||||||||||||
| if _lama_model is None: | ||||||||||||||||||||
| from lama_inpaint.runtime import LamaInpaint | ||||||||||||||||||||
| _lama_model = LamaInpaint(get_device()) | ||||||||||||||||||||
| logger.info("LaMa loaded") | ||||||||||||||||||||
| return _lama_model | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class DetectRequest(BaseModel): | ||||||||||||||||||||
| image_b64: str | ||||||||||||||||||||
| prompt: str = "watermark" | ||||||||||||||||||||
| max_bbox_percent: float = 10.0 | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class InpaintRequest(BaseModel): | ||||||||||||||||||||
| image_b64: str | ||||||||||||||||||||
| mask_b64: str | ||||||||||||||||||||
| double_pass: bool = False | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class RemoveRequest(BaseModel): | ||||||||||||||||||||
| image_b64: str | ||||||||||||||||||||
| prompt: str = "watermark" | ||||||||||||||||||||
| max_bbox_percent: float = 10.0 | ||||||||||||||||||||
| mask_mode: str = "box" | ||||||||||||||||||||
| double_pass: bool = False | ||||||||||||||||||||
| enhance: bool = False | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def b64_to_image(b64: str) -> Image.Image: | ||||||||||||||||||||
| data = base64.b64decode(b64.split(",")[-1]) | ||||||||||||||||||||
| return Image.open(io.BytesIO(data)).convert("RGB") | ||||||||||||||||||||
|
Comment on lines
+106
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Bound image inputs before decoding and inference.
🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def image_to_b64(img: Image.Image, fmt: str = "PNG") -> str: | ||||||||||||||||||||
| buf = io.BytesIO() | ||||||||||||||||||||
| if fmt == "JPEG": | ||||||||||||||||||||
| img.save(buf, format="JPEG", quality=100, subsampling=0) | ||||||||||||||||||||
| elif fmt == "WEBP": | ||||||||||||||||||||
| img.save(buf, format="WEBP", quality=100) | ||||||||||||||||||||
| else: | ||||||||||||||||||||
| img.save(buf, format="PNG", optimize=True) | ||||||||||||||||||||
| return base64.b64encode(buf.getvalue()).decode() | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| @app.get("/api/health") | ||||||||||||||||||||
| async def health(): | ||||||||||||||||||||
| return {"status": "ok", "device": get_device()} | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| @app.post("/api/detect") | ||||||||||||||||||||
| async def detect(req: DetectRequest): | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Move blocking inference off the event loop.
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||||||||||||
| """Detect watermarks/objects and return bounding boxes.""" | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| image = b64_to_image(req.image_b64) | ||||||||||||||||||||
| model, processor = get_florence() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from remwm import get_watermark_mask, detect_only | ||||||||||||||||||||
| detections = detect_only( | ||||||||||||||||||||
| np.array(image), model, processor, get_device(), | ||||||||||||||||||||
| req.max_bbox_percent, req.prompt | ||||||||||||||||||||
|
Comment on lines
+136
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Pass the PIL image to
Proposed fix--- "a/backend/api.py"
+++ "b/backend/api.py"
@@ -132,7 +132,7 @@
from remwm import get_watermark_mask, detect_only
detections = detect_only(
- np.array(image), model, processor, get_device(),
+ image, model, processor, get_device(),
req.max_bbox_percent, req.prompt
)
return {"detections": detections}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| ) | ||||||||||||||||||||
| return {"detections": detections} | ||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||
| raise HTTPException(status_code=500, detail=str(e)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| @app.post("/api/inpaint") | ||||||||||||||||||||
| async def inpaint(req: InpaintRequest): | ||||||||||||||||||||
| """Inpaint masked regions using LaMa.""" | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| image = b64_to_image(req.image_b64) | ||||||||||||||||||||
| mask = Image.open(io.BytesIO(base64.b64decode(req.mask_b64.split(",")[-1]))).convert("L") | ||||||||||||||||||||
| lama = get_lama() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from remwm import process_image_with_lama | ||||||||||||||||||||
| img_np = np.array(image) | ||||||||||||||||||||
| mask_np = np.array(mask) | ||||||||||||||||||||
| result = process_image_with_lama(img_np, mask_np, lama) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if req.double_pass: | ||||||||||||||||||||
| result = process_image_with_lama( | ||||||||||||||||||||
| cv2.cvtColor(result, cv2.COLOR_BGR2RGB), mask_np, lama | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| result_img = Image.fromarray(cv2.cvtColor(result, cv2.COLOR_BGR2RGB)) | ||||||||||||||||||||
| return {"result_b64": image_to_b64(result_img)} | ||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||
| raise HTTPException(status_code=500, detail=str(e)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| @app.post("/api/remove") | ||||||||||||||||||||
| async def remove(req: RemoveRequest): | ||||||||||||||||||||
| """One-click: detect + inpaint watermarks.""" | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| image = b64_to_image(req.image_b64) | ||||||||||||||||||||
| model, processor = get_florence() | ||||||||||||||||||||
| lama = get_lama() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from remwm import get_watermark_mask, process_image_with_lama | ||||||||||||||||||||
| mask = get_watermark_mask( | ||||||||||||||||||||
| image, model, processor, get_device(), | ||||||||||||||||||||
| req.max_bbox_percent, req.prompt, mask_mode=req.mask_mode | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| img_np = np.array(image) | ||||||||||||||||||||
| mask_np = np.array(mask) | ||||||||||||||||||||
| result = process_image_with_lama(img_np, mask_np, lama) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if req.double_pass: | ||||||||||||||||||||
| result = process_image_with_lama( | ||||||||||||||||||||
| cv2.cvtColor(result, cv2.COLOR_BGR2RGB), mask_np, lama | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| result_img = Image.fromarray(cv2.cvtColor(result, cv2.COLOR_BGR2RGB)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if req.enhance: | ||||||||||||||||||||
| from quality_pipeline import QualityPipeline | ||||||||||||||||||||
| qp = QualityPipeline(lama, get_device()) | ||||||||||||||||||||
| result_img = qp._enhance_inpainted_region(result_img, mask) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # Return mask for preview too | ||||||||||||||||||||
| mask_b64 = base64.b64encode( | ||||||||||||||||||||
| cv2.imencode(".png", np.array(mask))[1].tobytes() | ||||||||||||||||||||
| ).decode() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return { | ||||||||||||||||||||
| "result_b64": image_to_b64(result_img), | ||||||||||||||||||||
| "mask_b64": mask_b64, | ||||||||||||||||||||
| "image_width": image.width, | ||||||||||||||||||||
| "image_height": image.height, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||
| import traceback | ||||||||||||||||||||
| logger.error(f"/api/remove failed: {e}\n{traceback.format_exc()}") | ||||||||||||||||||||
| raise HTTPException(status_code=500, detail=str(e)) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| # Serve frontend static files at root | ||||||||||||||||||||
| import os | ||||||||||||||||||||
| _ui_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ui-openwipe") | ||||||||||||||||||||
| if os.path.isdir(_ui_dir): | ||||||||||||||||||||
| app.mount("/", StaticFiles(directory=_ui_dir, html=True), name="ui") | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||||||
| import uvicorn | ||||||||||||||||||||
| uvicorn.run(app, host="127.0.0.1", port=8765, log_level="info") | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Restrict access to the local inference API.
The API accepts requests without authentication, and
allow_origins=["*"]permits any browser origin to read its responses. Where a browser permits loopback access, an unrelated site can submit repeated inference requests and consume the desktop app’s CPU or GPU. Restrict CORS to the desktop origin and require a session secret for inference requests; CORS alone does not restrict non-browser clients. Browser loopback permissions can add a separate barrier, but do not make this API private. (fastapi.tiangolo.com)🤖 Prompt for AI Agents