Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- Dockerfile +15 -0
- LICENSE +21 -0
- README.md +26 -12
- requirements.txt +8 -0
- server.py +121 -0
- static/style.css +8 -0
- templates/index.html +61 -0
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY requirements.txt ./requirements.txt
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
|
| 7 |
+
COPY server.py ./server.py
|
| 8 |
+
COPY templates ./templates
|
| 9 |
+
COPY static ./static
|
| 10 |
+
COPY README.md ./README.md
|
| 11 |
+
COPY LICENSE ./LICENSE
|
| 12 |
+
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
CMD ["sh", "-c", "uvicorn server:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Waikul
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in
|
| 13 |
+
all copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
| 21 |
+
THE SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,26 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Watermark Remover (Hugging Face Space)
|
| 2 |
+
|
| 3 |
+
A simple FastAPI + OpenCV inpainting service to remove watermarks.
|
| 4 |
+
Deployable for free on Hugging Face Spaces using Docker.
|
| 5 |
+
|
| 6 |
+
## API
|
| 7 |
+
Endpoint: `POST /api/remove-watermark`
|
| 8 |
+
|
| 9 |
+
### Fields (multipart/form-data)
|
| 10 |
+
- `image`: file (required)
|
| 11 |
+
- `mask`: file (optional)
|
| 12 |
+
- `engine`: `opencv` (default) or `lama`
|
| 13 |
+
- `method`: `telea` (default) or `ns`
|
| 14 |
+
- `radius`: int (default 3, OpenCV only)
|
| 15 |
+
- `auto_mask`: 1 or 0 (default 1)
|
| 16 |
+
|
| 17 |
+
### Example
|
| 18 |
+
```bash
|
| 19 |
+
curl -X POST https://<your-space>.hf.space/api/remove-watermark \
|
| 20 |
+
-F image=@your_image.jpg \
|
| 21 |
+
-F engine=opencv -F method=telea -F radius=3 -F auto_mask=1 \
|
| 22 |
+
--output result.png
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## License
|
| 26 |
+
MIT License
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.112.2
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
python-multipart==0.0.9
|
| 4 |
+
Jinja2==3.1.4
|
| 5 |
+
opencv-python==4.10.0.84
|
| 6 |
+
numpy==1.26.4
|
| 7 |
+
Pillow==10.4.0
|
| 8 |
+
requests==2.32.3
|
server.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import requests
|
| 9 |
+
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request
|
| 10 |
+
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.templating import Jinja2Templates
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Watermark Remover API")
|
| 15 |
+
|
| 16 |
+
# Serve static + templates
|
| 17 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 18 |
+
templates = Jinja2Templates(directory="templates")
|
| 19 |
+
|
| 20 |
+
LAMA_URL = os.getenv("LAMA_URL", "http://localhost:5000") # optional
|
| 21 |
+
|
| 22 |
+
def read_image_to_cv2(file: UploadFile) -> np.ndarray:
|
| 23 |
+
data = file.file.read()
|
| 24 |
+
img_arr = np.frombuffer(data, np.uint8)
|
| 25 |
+
img = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
| 26 |
+
if img is None:
|
| 27 |
+
raise HTTPException(400, detail="Invalid image file")
|
| 28 |
+
return img
|
| 29 |
+
|
| 30 |
+
def pil_bytes_from_cv2(img: np.ndarray, fmt: str = "PNG") -> io.BytesIO:
|
| 31 |
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 32 |
+
pil_img = Image.fromarray(img_rgb)
|
| 33 |
+
buf = io.BytesIO()
|
| 34 |
+
pil_img.save(buf, format=fmt)
|
| 35 |
+
buf.seek(0)
|
| 36 |
+
return buf
|
| 37 |
+
|
| 38 |
+
def auto_text_mask(img: np.ndarray) -> np.ndarray:
|
| 39 |
+
"""Simple heuristic mask for text/logo-like overlays."""
|
| 40 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 41 |
+
gray = cv2.equalizeHist(gray)
|
| 42 |
+
mser = cv2.MSER_create(_delta=5, _min_area=60, _max_area=10000)
|
| 43 |
+
regions, _ = mser.detectRegions(gray)
|
| 44 |
+
mask = np.zeros(gray.shape, dtype=np.uint8)
|
| 45 |
+
for p in regions:
|
| 46 |
+
hull = cv2.convexHull(p.reshape(-1, 1, 2))
|
| 47 |
+
cv2.drawContours(mask, [hull], -1, 255, thickness=-1)
|
| 48 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
| 49 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
|
| 50 |
+
mask = cv2.dilate(mask, kernel, iterations=1)
|
| 51 |
+
return mask
|
| 52 |
+
|
| 53 |
+
def inpaint_opencv(img: np.ndarray, mask: np.ndarray, method: str = "telea", radius: int = 3) -> np.ndarray:
|
| 54 |
+
flag = cv2.INPAINT_TELEA if method.lower() == "telea" else cv2.INPAINT_NS
|
| 55 |
+
if mask.ndim == 3:
|
| 56 |
+
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
|
| 57 |
+
_, mask_bin = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)
|
| 58 |
+
result = cv2.inpaint(img, mask_bin, radius, flag)
|
| 59 |
+
return result
|
| 60 |
+
|
| 61 |
+
def call_lama_server(img: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 62 |
+
if mask.ndim == 3:
|
| 63 |
+
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
|
| 64 |
+
_, mask_bin = cv2.threshold(mask, 1, 255, cv2.THRESH_BINARY)
|
| 65 |
+
|
| 66 |
+
def to_png_bytes(arr: np.ndarray) -> bytes:
|
| 67 |
+
a = arr
|
| 68 |
+
if a.ndim == 2:
|
| 69 |
+
a = cv2.cvtColor(a, cv2.COLOR_GRAY2BGR)
|
| 70 |
+
ok, buf = cv2.imencode('.png', a)
|
| 71 |
+
if not ok:
|
| 72 |
+
raise HTTPException(500, detail="Encoding error")
|
| 73 |
+
return buf.tobytes()
|
| 74 |
+
|
| 75 |
+
files = {
|
| 76 |
+
'image': ('image.png', to_png_bytes(img), 'image/png'),
|
| 77 |
+
'mask': ('mask.png', to_png_bytes(mask_bin), 'image/png'),
|
| 78 |
+
}
|
| 79 |
+
data = {'method': 'lama'}
|
| 80 |
+
try:
|
| 81 |
+
resp = requests.post(f"{LAMA_URL}/inpaint", data=data, files=files, timeout=120)
|
| 82 |
+
resp.raise_for_status()
|
| 83 |
+
except Exception as e:
|
| 84 |
+
raise HTTPException(502, detail=f"LaMa server error: {e}")
|
| 85 |
+
nparr = np.frombuffer(resp.content, np.uint8)
|
| 86 |
+
out = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 87 |
+
if out is None:
|
| 88 |
+
raise HTTPException(502, detail="Invalid response from LaMa server")
|
| 89 |
+
return out
|
| 90 |
+
|
| 91 |
+
@app.get("/", response_class=HTMLResponse)
|
| 92 |
+
def index(request: Request):
|
| 93 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 94 |
+
|
| 95 |
+
@app.get("/health")
|
| 96 |
+
def health():
|
| 97 |
+
return JSONResponse({"ok": True})
|
| 98 |
+
|
| 99 |
+
@app.post("/api/remove-watermark")
|
| 100 |
+
def remove_watermark(
|
| 101 |
+
image: UploadFile = File(...),
|
| 102 |
+
mask: Optional[UploadFile] = File(None),
|
| 103 |
+
engine: str = Form("opencv"), # "opencv" | "lama"
|
| 104 |
+
method: str = Form("telea"), # opencv: telea | ns
|
| 105 |
+
radius: int = Form(3),
|
| 106 |
+
auto_mask: int = Form(1), # 1=true, 0=false
|
| 107 |
+
):
|
| 108 |
+
img = read_image_to_cv2(image)
|
| 109 |
+
|
| 110 |
+
if mask is not None:
|
| 111 |
+
mask_img = read_image_to_cv2(mask)
|
| 112 |
+
else:
|
| 113 |
+
mask_img = auto_text_mask(img) if auto_mask else np.zeros(img.shape[:2], dtype=np.uint8)
|
| 114 |
+
|
| 115 |
+
if engine == "lama":
|
| 116 |
+
out = call_lama_server(img, mask_img)
|
| 117 |
+
else:
|
| 118 |
+
out = inpaint_opencv(img, mask_img, method=method, radius=radius)
|
| 119 |
+
|
| 120 |
+
buf = pil_bytes_from_cv2(out, fmt="PNG")
|
| 121 |
+
return StreamingResponse(buf, media_type="image/png")
|
static/style.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body { font-family: system-ui, Arial, sans-serif; padding: 24px; }
|
| 2 |
+
main { max-width: 840px; margin: auto; }
|
| 3 |
+
h1 { margin-bottom: 16px; }
|
| 4 |
+
form label { display: block; margin: 8px 0; }
|
| 5 |
+
.row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
| 6 |
+
button { padding: 8px 12px; }
|
| 7 |
+
.hidden { display: none; }
|
| 8 |
+
#output img { max-width: 100%; height: auto; margin-top: 12px; border: 1px solid #ddd; }
|
templates/index.html
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Watermark Remover</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main>
|
| 11 |
+
<h1>Watermark Remover</h1>
|
| 12 |
+
<form id="form">
|
| 13 |
+
<label>Image <input type="file" name="image" accept="image/*" required></label>
|
| 14 |
+
<label>Mask (optional) <input type="file" name="mask" accept="image/*"></label>
|
| 15 |
+
<div class="row">
|
| 16 |
+
<label>Engine
|
| 17 |
+
<select name="engine">
|
| 18 |
+
<option value="opencv" selected>OpenCV (fast, free)</option>
|
| 19 |
+
<option value="lama">LaMa (needs separate server)</option>
|
| 20 |
+
</select>
|
| 21 |
+
</label>
|
| 22 |
+
<label>Method
|
| 23 |
+
<select name="method">
|
| 24 |
+
<option value="telea" selected>Telea</option>
|
| 25 |
+
<option value="ns">Navier-Stokes</option>
|
| 26 |
+
</select>
|
| 27 |
+
</label>
|
| 28 |
+
<label>Radius <input type="number" name="radius" value="3" min="1" max="50"></label>
|
| 29 |
+
<label>Auto Mask <input type="checkbox" name="auto_mask" checked></label>
|
| 30 |
+
</div>
|
| 31 |
+
<button type="submit">Remove</button>
|
| 32 |
+
</form>
|
| 33 |
+
|
| 34 |
+
<section id="output" class="hidden">
|
| 35 |
+
<h2>Result</h2>
|
| 36 |
+
<img id="result-img" alt="result" />
|
| 37 |
+
<a id="download" download="result.png">Download</a>
|
| 38 |
+
</section>
|
| 39 |
+
</main>
|
| 40 |
+
|
| 41 |
+
<script>
|
| 42 |
+
const form = document.getElementById('form');
|
| 43 |
+
const out = document.getElementById('output');
|
| 44 |
+
const img = document.getElementById('result-img');
|
| 45 |
+
const dl = document.getElementById('download');
|
| 46 |
+
|
| 47 |
+
form.addEventListener('submit', async (e) => {
|
| 48 |
+
e.preventDefault();
|
| 49 |
+
const data = new FormData(form);
|
| 50 |
+
data.set('auto_mask', form.auto_mask.checked ? 1 : 0);
|
| 51 |
+
const res = await fetch('/api/remove-watermark', { method: 'POST', body: data });
|
| 52 |
+
if (!res.ok) { alert('Failed: ' + res.status); return; }
|
| 53 |
+
const blob = await res.blob();
|
| 54 |
+
const url = URL.createObjectURL(blob);
|
| 55 |
+
img.src = url;
|
| 56 |
+
dl.href = url;
|
| 57 |
+
out.classList.remove('hidden');
|
| 58 |
+
});
|
| 59 |
+
</script>
|
| 60 |
+
</body>
|
| 61 |
+
</html>
|