Arena Agent commited on
Commit
91f053c
·
1 Parent(s): a250d07

Build advanced background removal studio

Browse files
Files changed (4) hide show
  1. Dockerfile +10 -6
  2. README.md +16 -12
  3. app.py +248 -422
  4. requirements.txt +3 -1
Dockerfile CHANGED
@@ -2,20 +2,24 @@ FROM python:3.11-slim
2
 
3
  ENV PYTHONDONTWRITEBYTECODE=1 \
4
  PYTHONUNBUFFERED=1 \
5
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
6
 
7
  WORKDIR /app
8
 
9
  RUN apt-get update \
10
- && apt-get install -y --no-install-recommends ca-certificates \
11
- && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
12
 
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir --upgrade pip \
15
- && pip install --no-cache-dir -r requirements.txt \
16
- && python -m playwright install --with-deps chromium
17
 
18
  COPY app.py .
19
 
20
  EXPOSE 7860
21
- CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "90", "app:app"]
 
2
 
3
  ENV PYTHONDONTWRITEBYTECODE=1 \
4
  PYTHONUNBUFFERED=1 \
5
+ U2NET_HOME=/models
6
 
7
  WORKDIR /app
8
 
9
  RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends \
11
+ ca-certificates \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ libgomp1 \
15
+ && rm -rf /var/lib/apt/lists/* \
16
+ && mkdir -p /models
17
 
18
  COPY requirements.txt .
19
  RUN pip install --no-cache-dir --upgrade pip \
20
+ && pip install --no-cache-dir -r requirements.txt
 
21
 
22
  COPY app.py .
23
 
24
  EXPOSE 7860
25
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "2", "--timeout", "180", "app:app"]
README.md CHANGED
@@ -1,24 +1,28 @@
1
  ---
2
- title: Web Test Lab
3
- emoji: 🧪
4
- colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Web Test Lab
12
 
13
- A safe screenshot-based website testing dashboard powered by Playwright and Chromium.
14
 
15
  ## Features
16
 
17
- - Test public HTTP and HTTPS websites
18
- - Desktop, tablet, and mobile viewport profiles
19
- - HTTP status, page title, load time, final URL, and screenshot
20
- - Search page at `/search`
21
- - Browser test page at `/`
22
- - Health check at `/health`
 
 
23
 
24
- The service returns screenshots and search results; it does not provide a remote interactive browser. Localhost and private-network addresses are blocked. Use it only on websites you own or are authorized to test.
 
 
 
1
  ---
2
+ title: Background Studio
3
+ emoji: ✂️
4
+ colorFrom: purple
5
  colorTo: blue
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Background Studio
12
 
13
+ An advanced background-removal web app powered by `rembg`.
14
 
15
  ## Features
16
 
17
+ - Drag-and-drop image upload
18
+ - Quality, balanced, fast, people, and anime models
19
+ - Optional alpha matting for hair, fur, and fine edges
20
+ - Before/after comparison slider
21
+ - Transparent PNG, WEBP, or JPG output
22
+ - Custom JPG background color
23
+ - 12 MB upload limit and basic rate limiting
24
+ - Optional `API_KEY` Space Secret for API protection
25
 
26
+ Open `/` for the web interface or call `POST /remove-bg` with a multipart `image` field.
27
+
28
+ For a public Space, add `API_KEY` under **Settings → Variables and secrets** if you want the processing endpoint protected. Do not commit `.env` files or credentials.
app.py CHANGED
@@ -1,40 +1,33 @@
1
- import base64
2
- import ipaddress
3
- import socket
4
  import time
5
  from threading import Lock
6
- from time import perf_counter
7
- from urllib.parse import parse_qs, urlencode, urlparse
8
 
9
- from flask import Flask, jsonify, request
10
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
11
- from playwright.sync_api import sync_playwright
 
 
 
12
 
13
  app = Flask(__name__)
14
- app.config["MAX_CONTENT_LENGTH"] = 32 * 1024
15
 
16
- TEST_TIMEOUT_MS = 20_000
17
- RATE_WINDOW_SECONDS = 60
18
- RATE_LIMIT = 12
 
 
 
 
 
 
 
19
  RATE_LOCK = Lock()
20
  RATE_BUCKETS = {}
21
- BROWSER_LOCK = Lock()
22
- PLAYWRIGHT = None
23
- BROWSER = None
24
-
25
- DEVICES = {
26
- "desktop": {"viewport": {"width": 1440, "height": 900}},
27
- "tablet": {
28
- "viewport": {"width": 834, "height": 1112},
29
- "is_mobile": True,
30
- "has_touch": True,
31
- },
32
- "mobile": {
33
- "viewport": {"width": 390, "height": 844},
34
- "is_mobile": True,
35
- "has_touch": True,
36
- },
37
- }
38
 
39
  INDEX_HTML = r"""
40
  <!doctype html>
@@ -42,277 +35,176 @@ INDEX_HTML = r"""
42
  <head>
43
  <meta charset="utf-8">
44
  <meta name="viewport" content="width=device-width, initial-scale=1">
45
- <title>Web Test Lab</title>
46
  <style>
47
- :root {
48
- color-scheme: dark;
49
- --bg: #090d18;
50
- --panel: rgba(20, 27, 49, .82);
51
- --panel-strong: #151d35;
52
- --line: rgba(148, 163, 184, .18);
53
- --text: #f8fafc;
54
- --muted: #93a4c3;
55
- --blue: #6d8cff;
56
- --cyan: #39d9c4;
57
- --danger: #ff7b91;
58
- --shadow: 0 24px 70px rgba(0, 0, 0, .35);
59
- }
60
- * { box-sizing: border-box; }
61
- body {
62
- margin: 0;
63
- min-height: 100vh;
64
- color: var(--text);
65
- font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
66
- background:
67
- radial-gradient(circle at 15% 0%, rgba(109, 140, 255, .24), transparent 33rem),
68
- radial-gradient(circle at 95% 15%, rgba(57, 217, 196, .13), transparent 28rem),
69
- var(--bg);
70
- }
71
- a { color: #9bb5ff; }
72
- .shell { width: min(1160px, calc(100% - 36px)); margin: 0 auto; padding: 34px 0 54px; }
73
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 46px; }
74
- .brand { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .04em; }
75
- .brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; color: #07111e; background: linear-gradient(135deg, var(--cyan), var(--blue)); box-shadow: 0 8px 26px rgba(57, 217, 196, .22); }
76
- .brand small { display: block; color: var(--muted); font-weight: 500; letter-spacing: 0; }
77
- .engine { display: inline-flex; align-items: center; gap: 8px; color: #b6c4de; font-size: 13px; }
78
- .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 14px var(--cyan); }
79
- .hero { max-width: 760px; margin-bottom: 30px; }
80
- .eyebrow { margin: 0 0 12px; color: var(--cyan); font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
81
- h1 { max-width: 680px; margin: 0; font-size: clamp(2.4rem, 6vw, 4.8rem); line-height: .98; letter-spacing: -.065em; }
82
- .hero p { max-width: 620px; margin: 20px 0 0; color: var(--muted); font-size: 17px; }
83
- .workspace { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; align-items: start; }
84
- .card { border: 1px solid var(--line); border-radius: 22px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
85
- .controls { padding: 22px; position: sticky; top: 18px; }
86
- .card-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; font-weight: 750; }
87
- .badge { padding: 5px 9px; border: 1px solid rgba(57, 217, 196, .26); border-radius: 999px; color: var(--cyan); background: rgba(57, 217, 196, .08); font-size: 11px; font-weight: 700; }
88
- label { display: block; margin: 18px 0 8px; color: #c8d4ea; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
89
- input[type=url] { width: 100%; padding: 13px 14px; border: 1px solid var(--line); border-radius: 12px; outline: 0; color: var(--text); background: rgba(5, 10, 22, .64); font: inherit; }
90
- input[type=url]:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(109, 140, 255, .14); }
91
- .presets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
92
- .preset, .device { border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: transparent; cursor: pointer; font: inherit; }
93
- .preset { padding: 6px 9px; font-size: 12px; }
94
- .preset:hover, .device:hover { border-color: var(--blue); color: var(--text); }
95
- .devices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
96
- .device { padding: 10px 5px; }
97
- .device.active { border-color: var(--blue); color: white; background: rgba(109, 140, 255, .16); }
98
- .run { width: 100%; margin-top: 24px; padding: 14px 16px; border: 0; border-radius: 12px; color: #07111e; background: linear-gradient(100deg, var(--cyan), #85a0ff); box-shadow: 0 12px 26px rgba(66, 137, 255, .2); cursor: pointer; font: inherit; font-weight: 850; }
99
- .run:disabled { opacity: .55; cursor: wait; }
100
- .notice { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
101
- .results { padding: 22px; min-height: 480px; }
102
- .result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
103
- .result-head h2 { margin: 0; font-size: 18px; }
104
- #status { color: var(--muted); font-size: 13px; }
105
- #status.error { color: var(--danger); }
106
- #status.success { color: var(--cyan); }
107
- .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 18px; }
108
- .metric { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(7, 12, 27, .42); }
109
- .metric-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
110
- .metric-value { overflow: hidden; margin-top: 4px; font-size: 17px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
111
- .preview { display: grid; place-items: center; min-height: 330px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: #0a1020; }
112
- .preview.empty::before { content: "Your browser screenshot will appear here"; color: #60708f; }
113
- #screenshot { display: block; width: 100%; max-height: 610px; object-fit: contain; }
114
- .result-foot { display: flex; justify-content: space-between; gap: 12px; margin-top: 14px; color: var(--muted); font-size: 12px; }
115
- .download { display: none; color: #b9c8ff; font-weight: 700; text-decoration: none; }
116
- .download.show { display: inline; }
117
- @media (max-width: 820px) { .workspace { grid-template-columns: 1fr; } .controls { position: static; } .topbar { margin-bottom: 32px; } }
118
- @media (max-width: 520px) { .shell { width: min(100% - 24px, 1160px); padding-top: 20px; } .engine { display: none; } .metrics { grid-template-columns: 1fr; } .result-foot { flex-direction: column; } }
119
  </style>
120
  </head>
121
  <body>
122
  <main class="shell">
123
  <header class="topbar">
124
- <div class="brand"><div class="brand-mark">✦</div><div>WEB TEST LAB<small>Browser-powered checks</small></div></div>
125
- <div class="engine"><a href="/search">Search web ↗</a><span class="dot"></span> Chromium ready</div>
126
  </header>
127
  <section class="hero">
128
- <p class="eyebrow">Live website testing</p>
129
- <h1>See how your site behaves before users do.</h1>
130
- <p>Enter a public URL, choose a device profile, and run a real headless Chromium check with a visual screenshot. Need to find something? <a href="/search">Search the web</a>.</p>
131
  </section>
132
- <section class="workspace">
133
  <aside class="card controls">
134
- <div class="card-title"><span>Test setup</span><span class="badge">SECURE PREVIEW</span></div>
135
- <form id="test-form">
136
- <label for="url">Website URL</label>
137
- <input id="url" type="url" value="https://example.com" placeholder="https://your-site.com" required>
138
- <div class="presets">
139
- <button class="preset" type="button" data-url="https://example.com">Example</button>
140
- <button class="preset" type="button" data-url="https://huggingface.co">Hugging Face</button>
 
 
141
  </div>
142
- <label>Viewport</label>
143
- <div class="devices">
144
- <button class="device active" type="button" data-device="desktop">Desktop</button>
145
- <button class="device" type="button" data-device="tablet">Tablet</button>
146
- <button class="device" type="button" data-device="mobile">Mobile</button>
 
 
 
 
 
 
147
  </div>
148
- <button class="run" id="run" type="submit">Run browser test </button>
 
 
 
 
 
149
  </form>
150
- <p class="notice">Only public HTTP and HTTPS websites are allowed. Use this tool only on sites you own or are authorized to test.</p>
151
  </aside>
152
- <section class="card results">
153
- <div class="result-head"><h2>Test result</h2><span id="status">Ready when you are</span></div>
154
- <div class="metrics">
155
- <div class="metric"><div class="metric-label">HTTP status</div><div class="metric-value" id="http-status">—</div></div>
156
- <div class="metric"><div class="metric-label">Load time</div><div class="metric-value" id="load-time"></div></div>
157
- <div class="metric"><div class="metric-label">Page title</div><div class="metric-value" id="page-title">—</div></div>
 
158
  </div>
159
- <div class="preview empty" id="preview"><img id="screenshot" alt="Website screenshot" hidden></div>
160
- <div class="result-foot"><span id="final-url">No test run yet</span><a class="download" id="download" download="website-screenshot.png">Download screenshot</a></div>
161
  </section>
162
  </section>
163
  </main>
164
  <script>
165
- const form = document.getElementById('test-form');
166
- const urlInput = document.getElementById('url');
167
- const runButton = document.getElementById('run');
 
 
168
  const status = document.getElementById('status');
169
- const preview = document.getElementById('preview');
170
- const screenshot = document.getElementById('screenshot');
 
 
 
 
 
171
  const download = document.getElementById('download');
172
- const httpStatus = document.getElementById('http-status');
173
- const loadTime = document.getElementById('load-time');
174
- const pageTitle = document.getElementById('page-title');
175
- const finalUrl = document.getElementById('final-url');
176
- let device = 'desktop';
177
- document.querySelectorAll('.device').forEach((button) => button.addEventListener('click', () => {
178
- device = button.dataset.device;
179
- document.querySelectorAll('.device').forEach((item) => item.classList.toggle('active', item === button));
180
- }));
181
- document.querySelectorAll('.preset').forEach((button) => button.addEventListener('click', () => { urlInput.value = button.dataset.url; }));
182
- form.addEventListener('submit', async (event) => {
183
- event.preventDefault();
184
- runButton.disabled = true;
185
- status.className = '';
186
- status.textContent = 'Opening Chromium…';
187
- preview.classList.add('empty');
188
- screenshot.hidden = true;
189
- download.classList.remove('show');
190
- try {
191
- const response = await fetch('/api/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: urlInput.value, device }) });
192
- const data = await response.json();
193
- if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`);
194
- httpStatus.textContent = data.status ?? '—';
195
- loadTime.textContent = `${data.load_time_ms} ms`;
196
- pageTitle.textContent = data.title || '(untitled)';
197
- finalUrl.textContent = data.url;
198
- screenshot.src = `data:image/png;base64,${data.screenshot}`;
199
- screenshot.hidden = false;
200
- preview.classList.remove('empty');
201
- download.href = screenshot.src;
202
- download.classList.add('show');
203
- status.className = data.ok ? 'success' : 'error';
204
- status.textContent = data.ok ? 'Test completed' : 'Page returned an error status';
205
- } catch (error) {
206
- status.className = 'error';
207
- status.textContent = error.message;
208
- httpStatus.textContent = '—';
209
- loadTime.textContent = '—';
210
- pageTitle.textContent = '—';
211
- finalUrl.textContent = 'Test did not complete';
212
- } finally {
213
- runButton.disabled = false;
214
- }
215
- });
216
- </script>
217
- </body>
218
- </html>
219
- """
220
-
221
- SEARCH_HTML = r"""
222
- <!doctype html>
223
- <html lang="en">
224
- <head>
225
- <meta charset="utf-8">
226
- <meta name="viewport" content="width=device-width, initial-scale=1">
227
- <title>Web Search · Web Test Lab</title>
228
- <style>
229
- :root { color-scheme: dark; --bg:#080c17; --panel:#121a30; --line:rgba(148,163,184,.18); --text:#f8fafc; --muted:#92a2bf; --blue:#7b96ff; --cyan:#43ddc6; --pink:#ff8eaa; }
230
- * { box-sizing:border-box; }
231
- body { margin:0; min-height:100vh; color:var(--text); font:15px/1.55 Inter,ui-sans-serif,system-ui,sans-serif; background:radial-gradient(circle at 20% 0%,rgba(123,150,255,.24),transparent 34rem),radial-gradient(circle at 90% 15%,rgba(67,221,198,.13),transparent 30rem),var(--bg); }
232
- a { color:#abc0ff; }
233
- .wrap { width:min(940px,calc(100% - 32px)); margin:0 auto; padding:28px 0 56px; }
234
- nav { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:68px; }
235
- .brand { display:flex; align-items:center; gap:10px; color:var(--text); font-weight:850; letter-spacing:.05em; text-decoration:none; }
236
- .mark { display:grid; place-items:center; width:35px; height:35px; border-radius:11px; color:#07111e; background:linear-gradient(135deg,var(--cyan),var(--blue)); }
237
- .engine { color:var(--muted); font-size:13px; }
238
- .hero { text-align:center; }
239
- .eyebrow { margin:0 0 14px; color:var(--cyan); font-size:12px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
240
- h1 { margin:0; font-size:clamp(2.5rem,8vw,5.2rem); line-height:.95; letter-spacing:-.07em; }
241
- .hero p { max-width:600px; margin:20px auto 0; color:var(--muted); font-size:17px; }
242
- .search-box { display:flex; gap:10px; max-width:760px; margin:32px auto 0; padding:8px; border:1px solid rgba(123,150,255,.38); border-radius:16px; background:rgba(11,17,34,.8); box-shadow:0 20px 60px rgba(0,0,0,.25); }
243
- input { min-width:0; flex:1; padding:13px 14px; border:0; outline:0; color:var(--text); background:transparent; font:inherit; }
244
- button { padding:0 22px; border:0; border-radius:11px; color:#07111e; background:linear-gradient(100deg,var(--cyan),#8da6ff); cursor:pointer; font:inherit; font-weight:850; }
245
- button:disabled { opacity:.55; cursor:wait; }
246
- .chips { display:flex; justify-content:center; flex-wrap:wrap; gap:8px; margin-top:16px; }
247
- .chip { padding:7px 11px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:transparent; cursor:pointer; font:inherit; font-size:12px; }
248
- .chip:hover { border-color:var(--blue); color:var(--text); }
249
- .meta { min-height:23px; margin:42px 0 14px; color:var(--muted); font-size:13px; }
250
- .results { display:grid; gap:10px; }
251
- .result { padding:18px 20px; border:1px solid var(--line); border-radius:16px; background:rgba(18,26,48,.78); transition:transform .16s,border-color .16s; }
252
- .result:hover { transform:translateY(-2px); border-color:rgba(123,150,255,.55); }
253
- .result-top { display:flex; gap:13px; align-items:flex-start; }
254
- .num { flex:0 0 auto; display:grid; place-items:center; width:27px; height:27px; border-radius:9px; color:var(--cyan); background:rgba(67,221,198,.1); font-size:12px; font-weight:800; }
255
- .title { display:block; color:#c4d1ff; font-size:17px; font-weight:750; text-decoration:none; }
256
- .title:hover { text-decoration:underline; }
257
- .url { overflow:hidden; margin-top:3px; color:#6fcdbd; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
258
- .snippet { margin:10px 0 0 40px; color:var(--muted); }
259
- .empty { padding:42px 20px; border:1px dashed var(--line); border-radius:16px; color:var(--muted); text-align:center; }
260
- footer { margin-top:42px; color:#6d7b96; font-size:12px; text-align:center; }
261
- @media (max-width:560px) { .wrap{width:min(100% - 22px,940px);padding-top:18px;} nav{margin-bottom:48px;} .engine{display:none;} .search-box{margin-top:25px;} button{padding:0 15px;} .snippet{margin-left:0;} }
262
- </style>
263
- </head>
264
- <body>
265
- <main class="wrap">
266
- <nav><a class="brand" href="/"><span class="mark">✦</span> WEB TEST LAB</a><span class="engine">Chromium-powered web search</span></nav>
267
- <section class="hero">
268
- <p class="eyebrow">Search the open web</p>
269
- <h1>Find what you need.</h1>
270
- <p>Search from a clean headless Chromium browser and open results in a new tab.</p>
271
- <form class="search-box" id="search-form">
272
- <input id="query" type="search" placeholder="Search the web…" autocomplete="off" required>
273
- <button id="search-button" type="submit">Search</button>
274
- </form>
275
- <div class="chips"><button class="chip" type="button" data-query="web development news">Web development</button><button class="chip" type="button" data-query="AI tools">AI tools</button><button class="chip" type="button" data-query="Bengaluru weather">Bengaluru weather</button></div>
276
- </section>
277
- <div class="meta" id="meta">Search results will appear here</div>
278
- <section class="results" id="results"><div class="empty">Enter a search query to begin.</div></section>
279
- <footer>Search uses public web results. Use this service responsibly and follow each website's terms.</footer>
280
- </main>
281
- <script>
282
- const form = document.getElementById('search-form');
283
- const queryInput = document.getElementById('query');
284
- const button = document.getElementById('search-button');
285
  const meta = document.getElementById('meta');
286
- const results = document.getElementById('results');
287
- document.querySelectorAll('.chip').forEach((chip) => chip.addEventListener('click', () => { queryInput.value = chip.dataset.query; form.requestSubmit(); }));
 
 
 
 
 
 
 
288
  form.addEventListener('submit', async (event) => {
289
  event.preventDefault();
290
- const query = queryInput.value.trim();
291
- if (!query) return;
292
- button.disabled = true;
293
- button.textContent = 'Searching…';
294
- meta.textContent = 'Opening Chromium and searching…';
295
- results.textContent = '';
296
  try {
297
- const response = await fetch('/api/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({query}) });
298
- const data = await response.json();
299
- if (!response.ok) throw new Error(data.error || `Search failed: ${response.status}`);
300
- meta.textContent = `${data.results.length} results for “${data.query}” · ${data.elapsed_ms} ms`;
301
- if (!data.results.length) { results.innerHTML = '<div class="empty">No results found. Try a different search.</div>'; return; }
302
- data.results.forEach((item, index) => {
303
- const card = document.createElement('article'); card.className = 'result';
304
- const top = document.createElement('div'); top.className = 'result-top';
305
- const num = document.createElement('span'); num.className = 'num'; num.textContent = String(index + 1);
306
- const body = document.createElement('div');
307
- const title = document.createElement('a'); title.className = 'title'; title.href = item.url; title.target = '_blank'; title.rel = 'noopener noreferrer'; title.textContent = item.title || item.url;
308
- const url = document.createElement('div'); url.className = 'url'; url.textContent = item.url;
309
- const snippet = document.createElement('p'); snippet.className = 'snippet'; snippet.textContent = item.snippet || 'Open this result in a new tab.';
310
- body.append(title, url); top.append(num, body); card.append(top, snippet); results.append(card);
311
- });
312
- } catch (error) {
313
- meta.textContent = error.message;
314
- results.innerHTML = '<div class="empty">Search could not be completed. Please try again.</div>';
315
- } finally { button.disabled = false; button.textContent = 'Search'; }
316
  });
317
  </script>
318
  </body>
@@ -322,14 +214,14 @@ SEARCH_HTML = r"""
322
 
323
  def _client_key():
324
  forwarded = request.headers.get("X-Forwarded-For", "")
325
- return (forwarded.split(",")[0].strip() or request.remote_addr or "unknown")
326
 
327
 
328
  def _within_rate_limit():
329
  now = time.time()
330
  key = _client_key()
331
  with RATE_LOCK:
332
- recent = [stamp for stamp in RATE_BUCKETS.get(key, []) if now - stamp < RATE_WINDOW_SECONDS]
333
  if len(recent) >= RATE_LIMIT:
334
  RATE_BUCKETS[key] = recent
335
  return False
@@ -338,122 +230,39 @@ def _within_rate_limit():
338
  return True
339
 
340
 
341
- def _public_url(value):
342
- if not isinstance(value, str) or len(value.strip()) > 2048:
343
- raise ValueError("Enter a valid public URL")
344
- value = value.strip()
345
- parsed = urlparse(value)
346
- if parsed.scheme not in {"http", "https"} or not parsed.hostname:
347
- raise ValueError("URL must start with http:// or https://")
348
- if parsed.username or parsed.password:
349
- raise ValueError("URLs with embedded credentials are not allowed")
350
- host = parsed.hostname.lower().rstrip(".")
351
- if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
352
- raise ValueError("Local network URLs are not allowed")
353
- try:
354
- addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
355
- except socket.gaierror as exc:
356
- raise ValueError("The hostname could not be resolved") from exc
357
- if not addresses or any(not ipaddress.ip_address(address).is_global for address in addresses):
358
- raise ValueError("Only public internet URLs are allowed")
359
- return value
360
 
361
 
362
- def _route_guard(route):
363
- target = route.request.url
364
- if target.startswith(("data:", "blob:", "about:", "chrome:")):
365
- route.continue_()
366
- return
367
  try:
368
- _public_url(target)
369
- except ValueError:
370
- route.abort()
371
- else:
372
- route.continue_()
373
 
374
 
375
- def _browser_test(target_url, device):
376
- global PLAYWRIGHT, BROWSER
377
- with BROWSER_LOCK:
378
- if BROWSER is None or not BROWSER.is_connected():
379
- if PLAYWRIGHT is not None:
380
- PLAYWRIGHT.stop()
381
- PLAYWRIGHT = sync_playwright().start()
382
- BROWSER = PLAYWRIGHT.chromium.launch(
383
- headless=True,
384
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
385
- )
386
- context = BROWSER.new_context(**DEVICES[device])
387
- page = context.new_page()
388
- page.route("**/*", _route_guard)
389
- started = perf_counter()
390
- try:
391
- response = page.goto(target_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
392
- page.wait_for_timeout(500)
393
- screenshot = base64.b64encode(page.screenshot(type="png", full_page=False)).decode("ascii")
394
- status_code = response.status if response is not None else None
395
- return {
396
- "ok": status_code is not None and 200 <= status_code < 400,
397
- "status": status_code,
398
- "title": page.title(),
399
- "url": page.url,
400
- "load_time_ms": round((perf_counter() - started) * 1000),
401
- "screenshot": screenshot,
402
- }
403
- finally:
404
- context.close()
405
 
406
 
407
- def _web_search(query):
408
- global PLAYWRIGHT, BROWSER
409
- search_url = "https://html.duckduckgo.com/html/?" + urlencode({"q": query})
410
- with BROWSER_LOCK:
411
- if BROWSER is None or not BROWSER.is_connected():
412
- if PLAYWRIGHT is not None:
413
- PLAYWRIGHT.stop()
414
- PLAYWRIGHT = sync_playwright().start()
415
- BROWSER = PLAYWRIGHT.chromium.launch(
416
- headless=True,
417
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
418
- )
419
- context = BROWSER.new_context(viewport={"width": 1280, "height": 900})
420
- page = context.new_page()
421
- page.route("**/*", _route_guard)
422
- started = perf_counter()
423
- try:
424
- page.goto(search_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
425
- page.wait_for_timeout(700)
426
- rows = page.locator("a.result__a, a[data-testid='result-title-a']").evaluate_all(
427
- """
428
- (links) => links.slice(0, 8).map((link) => {
429
- const result = link.closest('.result') || link.closest('[data-testid="result"]') || link.parentElement;
430
- const snippet = result?.querySelector('.result__snippet')?.innerText || '';
431
- return { title: link.innerText.trim(), url: link.href, snippet: snippet.trim() };
432
- })
433
- """
434
- )
435
- results = []
436
- for row in rows:
437
- href = row.get("url", "")
438
- parsed = urlparse(href)
439
- if parsed.hostname in {"duckduckgo.com", "www.duckduckgo.com"} and parsed.path.startswith("/l/"):
440
- href = parse_qs(parsed.query).get("uddg", [href])[0]
441
- try:
442
- href = _public_url(href)
443
- except ValueError:
444
- continue
445
- results.append({
446
- "title": row.get("title") or href,
447
- "url": href,
448
- "snippet": row.get("snippet", ""),
449
- })
450
- return {
451
- "query": query,
452
- "results": results,
453
- "elapsed_ms": round((perf_counter() - started) * 1000),
454
- }
455
- finally:
456
- context.close()
457
 
458
 
459
  @app.get("/")
@@ -461,52 +270,69 @@ def home():
461
  return INDEX_HTML
462
 
463
 
464
- @app.get("/search")
465
- def search_page():
466
- return SEARCH_HTML
467
 
468
 
469
- @app.post("/api/search")
470
- def search_api():
471
  if not _within_rate_limit():
472
  return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
473
- payload = request.get_json(silent=True) or {}
474
- query = payload.get("query", "")
475
- if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
476
- return jsonify({"error": "Enter a search query between 2 and 200 characters."}), 400
477
- try:
478
- return jsonify(_web_search(query.strip()))
479
- except PlaywrightTimeoutError:
480
- return jsonify({"error": "The search took too long to complete."}), 504
481
- except Exception:
482
- app.logger.exception("Web search failed")
483
- return jsonify({"error": "The web search could not be completed."}), 502
484
 
 
 
 
 
 
 
 
 
 
 
485
 
486
- @app.post("/api/test")
487
- def run_test():
488
- if not _within_rate_limit():
489
- return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
490
- payload = request.get_json(silent=True) or {}
491
- device = payload.get("device", "desktop")
492
- if device not in DEVICES:
493
- return jsonify({"error": "Unknown viewport"}), 400
494
  try:
495
- target_url = _public_url(payload.get("url"))
496
- return jsonify(_browser_test(target_url, device))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  except ValueError as exc:
498
  return jsonify({"error": str(exc)}), 400
499
- except PlaywrightTimeoutError:
500
- return jsonify({"error": "The page took too long to load."}), 504
501
  except Exception:
502
- app.logger.exception("Browser test failed")
503
- return jsonify({"error": "The browser test could not be completed."}), 502
504
-
505
-
506
- @app.get("/health")
507
- def health_check():
508
- return jsonify({"status": "healthy", "browser": "chromium"}), 200
509
 
510
 
511
  if __name__ == "__main__":
512
- app.run(host="0.0.0.0", port=7860)
 
1
+ import hmac
2
+ import io
3
+ import os
4
  import time
5
  from threading import Lock
 
 
6
 
7
+ from dotenv import load_dotenv
8
+ from flask import Flask, jsonify, request, send_file
9
+ from PIL import Image, ImageColor, ImageOps, UnidentifiedImageError
10
+ from rembg import new_session, remove
11
+
12
+ load_dotenv()
13
 
14
  app = Flask(__name__)
15
+ app.config["MAX_CONTENT_LENGTH"] = 12 * 1024 * 1024 # 12 MB upload limit
16
 
17
+ MODELS = {
18
+ "isnet-general-use": "Quality · ISNet General",
19
+ "u2net": "Balanced · U2Net",
20
+ "u2netp": "Fast · U2NetP",
21
+ "u2net_human_seg": "People · Human Segmentation",
22
+ "isnet-anime": "Anime · ISNet Anime",
23
+ }
24
+ FORMATS = {"png", "jpg", "webp"}
25
+ SESSIONS = {}
26
+ SESSION_LOCK = Lock()
27
  RATE_LOCK = Lock()
28
  RATE_BUCKETS = {}
29
+ RATE_LIMIT = 8
30
+ RATE_WINDOW = 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  INDEX_HTML = r"""
33
  <!doctype html>
 
35
  <head>
36
  <meta charset="utf-8">
37
  <meta name="viewport" content="width=device-width, initial-scale=1">
38
+ <title>Background Studio</title>
39
  <style>
40
+ :root { color-scheme: dark; --bg:#090d18; --panel:#121a2e; --panel2:#18233d; --line:rgba(148,163,184,.2); --text:#f7f9ff; --muted:#95a5c2; --cyan:#43ddc6; --blue:#8298ff; --pink:#ff8eaa; --shadow:0 26px 80px rgba(0,0,0,.32); }
41
+ * { box-sizing:border-box; }
42
+ body { min-height:100vh; margin:0; color:var(--text); font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; background:radial-gradient(circle at 12% 0%,rgba(130,152,255,.24),transparent 34rem),radial-gradient(circle at 94% 17%,rgba(67,221,198,.13),transparent 30rem),var(--bg); }
43
+ button,input,select { font:inherit; }
44
+ button { cursor:pointer; }
45
+ .shell { width:min(1180px,calc(100% - 34px)); margin:0 auto; padding:30px 0 58px; }
46
+ .topbar { display:flex; align-items:center; justify-content:space-between; gap:20px; }
47
+ .brand { display:flex; align-items:center; gap:11px; font-weight:850; letter-spacing:.05em; }
48
+ .brand-mark { display:grid; place-items:center; width:39px; height:39px; border-radius:13px; color:#06111b; background:linear-gradient(135deg,var(--cyan),var(--blue)); box-shadow:0 9px 28px rgba(67,221,198,.2); }
49
+ .brand small { display:block; color:var(--muted); font-weight:500; letter-spacing:0; }
50
+ .system { display:inline-flex; align-items:center; gap:8px; color:#b7c6df; font-size:13px; }
51
+ .dot { width:8px; height:8px; border-radius:50%; background:var(--cyan); box-shadow:0 0 13px var(--cyan); }
52
+ .hero { max-width:770px; margin:72px 0 34px; }
53
+ .eyebrow { margin:0 0 13px; color:var(--cyan); font-size:12px; font-weight:850; letter-spacing:.15em; text-transform:uppercase; }
54
+ h1 { margin:0; font-size:clamp(2.7rem,7vw,5.5rem); line-height:.93; letter-spacing:-.075em; }
55
+ .hero p { max-width:650px; margin:22px 0 0; color:var(--muted); font-size:18px; }
56
+ .studio { display:grid; grid-template-columns:380px minmax(0,1fr); gap:18px; align-items:start; }
57
+ .card { border:1px solid var(--line); border-radius:23px; background:rgba(18,26,46,.82); box-shadow:var(--shadow); backdrop-filter:blur(16px); }
58
+ .controls { padding:22px; position:sticky; top:18px; }
59
+ .card-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; font-weight:800; }
60
+ .pill { padding:5px 9px; border:1px solid rgba(67,221,198,.25); border-radius:999px; color:var(--cyan); background:rgba(67,221,198,.08); font-size:11px; font-weight:800; }
61
+ .dropzone { display:grid; place-items:center; min-height:166px; padding:22px; border:1px dashed rgba(130,152,255,.55); border-radius:17px; color:var(--muted); background:rgba(8,13,28,.45); text-align:center; transition:.18s; }
62
+ .dropzone.drag { border-color:var(--cyan); background:rgba(67,221,198,.08); transform:scale(1.01); }
63
+ .upload-icon { display:grid; place-items:center; width:44px; height:44px; margin-bottom:10px; border-radius:14px; color:var(--cyan); background:rgba(67,221,198,.1); font-size:21px; }
64
+ .dropzone strong { display:block; color:var(--text); }
65
+ .dropzone span { display:block; margin-top:4px; font-size:12px; }
66
+ .file-name { overflow:hidden; max-width:100%; margin-top:10px; color:#b8c8ff; text-overflow:ellipsis; white-space:nowrap; }
67
+ .browse { display:inline-block; margin-top:13px; padding:8px 12px; border:1px solid var(--line); border-radius:9px; color:var(--text); background:rgba(130,152,255,.12); cursor:pointer; font-size:12px; font-weight:750; }
68
+ #file { display:none; }
69
+ label { display:block; margin:18px 0 8px; color:#c9d5ec; font-size:11px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; }
70
+ select,input[type=password],input[type=color] { width:100%; padding:11px 12px; border:1px solid var(--line); border-radius:11px; outline:0; color:var(--text); background:rgba(7,12,25,.62); }
71
+ select:focus,input:focus { border-color:var(--blue); box-shadow:0 0 0 4px rgba(130,152,255,.13); }
72
+ input[type=color] { height:42px; padding:5px; }
73
+ .row { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
74
+ .check { display:flex; align-items:center; gap:9px; margin-top:17px; color:#c5d2e9; font-size:13px; }
75
+ .check input { width:16px; height:16px; accent-color:var(--cyan); }
76
+ details { margin-top:14px; color:var(--muted); font-size:12px; }
77
+ summary { cursor:pointer; color:#b8c8ff; font-weight:750; }
78
+ .advanced { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; margin-top:10px; }
79
+ .advanced input { padding:8px; font-size:12px; }
80
+ .advanced label { margin:0 0 5px; font-size:10px; }
81
+ .run { width:100%; margin-top:22px; padding:14px 16px; border:0; border-radius:12px; color:#06111b; background:linear-gradient(100deg,var(--cyan),#91a7ff); box-shadow:0 12px 30px rgba(67,221,198,.15); font-weight:850; }
82
+ .run:disabled { opacity:.55; cursor:wait; }
83
+ .status { min-height:21px; margin:13px 0 0; color:var(--muted); font-size:12px; }
84
+ .status.success { color:var(--cyan); }
85
+ .status.error { color:var(--pink); }
86
+ .result { min-width:0; padding:22px; }
87
+ .result-head { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:18px; }
88
+ .result-head h2 { margin:0; font-size:19px; }
89
+ .result-head span { color:var(--muted); font-size:12px; }
90
+ .empty { display:grid; place-items:center; min-height:500px; border:1px dashed var(--line); border-radius:17px; color:#687995; text-align:center; }
91
+ .empty-icon { margin-bottom:9px; font-size:32px; opacity:.7; }
92
+ .compare-wrap { display:none; }
93
+ .compare-wrap.show { display:block; }
94
+ .compare { position:relative; overflow:hidden; min-height:430px; border:1px solid var(--line); border-radius:17px; background-color:#edf2fa; background-image:linear-gradient(45deg,#dfe6f2 25%,transparent 25%),linear-gradient(-45deg,#dfe6f2 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#dfe6f2 75%),linear-gradient(-45deg,transparent 75%,#dfe6f2 75%); background-position:0 0,0 11px,11px -11px,-11px 0; background-size:22px 22px; }
95
+ .compare img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; }
96
+ .after-clip { position:absolute; inset:0; clip-path:inset(0 50% 0 0); }
97
+ .divider { position:absolute; top:0; bottom:0; left:50%; width:2px; background:white; box-shadow:0 0 8px rgba(0,0,0,.4); pointer-events:none; }
98
+ .tag { position:absolute; top:13px; z-index:2; padding:5px 8px; border-radius:7px; color:white; background:rgba(5,10,22,.65); font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; }
99
+ .tag.before { left:13px; }
100
+ .tag.after { right:13px; }
101
+ .range { width:100%; margin:18px 0 0; accent-color:var(--blue); cursor:ew-resize; }
102
+ .result-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; color:var(--muted); font-size:12px; }
103
+ .download { display:none; padding:8px 11px; border:1px solid rgba(67,221,198,.28); border-radius:9px; color:var(--cyan); text-decoration:none; font-weight:800; }
104
+ .download.show { display:inline-block; }
105
+ .hint { margin-top:16px; color:#6e7e9b; font-size:12px; }
106
+ @media (max-width:850px) { .studio{grid-template-columns:1fr;} .controls{position:static;} }
107
+ @media (max-width:540px) { .shell{width:min(100% - 22px,1180px);padding-top:20px;} .system{display:none;} .hero{margin-top:55px;} .row,.advanced{grid-template-columns:1fr;} .compare{min-height:300px;} }
 
 
 
 
108
  </style>
109
  </head>
110
  <body>
111
  <main class="shell">
112
  <header class="topbar">
113
+ <div class="brand"><span class="brand-mark">✦</span><div>BACKGROUND STUDIO<small>Advanced cutout workspace</small></div></div>
114
+ <div class="system"><span class="dot"></span> CPU processing ready</div>
115
  </header>
116
  <section class="hero">
117
+ <p class="eyebrow">Clean edges. Better cutouts.</p>
118
+ <h1>Make the subject stand out.</h1>
119
+ <p>Upload an image, choose the right segmentation model, fine-tune the edges, and download a transparent result.</p>
120
  </section>
121
+ <section class="studio">
122
  <aside class="card controls">
123
+ <div class="card-head"><span>Removal settings</span><span class="pill">ADVANCED</span></div>
124
+ <form id="form">
125
+ <div class="dropzone" id="dropzone">
126
+ <div class="upload-icon">↑</div>
127
+ <strong>Drop an image here</strong>
128
+ <span>PNG, JPG, WEBP · up to 12 MB</span>
129
+ <label class="browse" for="file">Choose image</label>
130
+ <input id="file" type="file" accept="image/png,image/jpeg,image/webp">
131
+ <div class="file-name" id="file-name">No file selected</div>
132
  </div>
133
+ <label for="model">Segmentation model</label>
134
+ <select id="model">
135
+ <option value="isnet-general-use">Quality · ISNet General</option>
136
+ <option value="u2net">Balanced · U2Net</option>
137
+ <option value="u2netp">Fast · U2NetP</option>
138
+ <option value="u2net_human_seg">People · Human Segmentation</option>
139
+ <option value="isnet-anime">Anime · ISNet Anime</option>
140
+ </select>
141
+ <div class="row">
142
+ <div><label for="format">Output</label><select id="format"><option value="png">PNG · transparent</option><option value="webp">WEBP · smaller</option><option value="jpg">JPG · solid background</option></select></div>
143
+ <div><label for="background">JPG background</label><input id="background" type="color" value="#ffffff" title="Used when JPG output is selected"></div>
144
  </div>
145
+ <label class="check"><input id="matting" type="checkbox"> Enable alpha matting for finer edges</label>
146
+ <details><summary>Fine-tune alpha matting</summary><div class="advanced"><div><label for="foreground">Foreground</label><input id="foreground" type="number" min="0" max="255" value="240"></div><div><label for="threshold">Background</label><input id="threshold" type="number" min="0" max="255" value="10"></div><div><label for="erode">Erode</label><input id="erode" type="number" min="0" max="40" value="10"></div></div></details>
147
+ <label for="api-key">API key <span style="color:var(--muted);font-weight:500;text-transform:none;letter-spacing:0">(optional)</span></label>
148
+ <input id="api-key" type="password" autocomplete="off" placeholder="Only needed if the Space secret is enabled">
149
+ <button class="run" id="run" type="submit">Remove background →</button>
150
+ <div class="status" id="status">Choose an image to begin.</div>
151
  </form>
 
152
  </aside>
153
+ <section class="card result">
154
+ <div class="result-head"><h2>Preview</h2><span id="meta">Before and after comparison</span></div>
155
+ <div class="empty" id="empty"><div><div class="empty-icon">◌</div>Upload an image and your result will appear here.</div></div>
156
+ <div class="compare-wrap" id="compare-wrap">
157
+ <div class="compare" id="compare"><span class="tag before">Original</span><span class="tag after">Cutout</span><img id="before" alt="Original image"><div class="after-clip" id="after-clip"><img id="after" alt="Background removed image"></div><div class="divider" id="divider"></div></div>
158
+ <input class="range" id="range" type="range" min="0" max="100" value="50" aria-label="Compare original and cutout">
159
+ <div class="result-actions"><span id="details">Transparent PNG ready</span><a class="download" id="download" download="background-removed.png">Download result ↓</a></div>
160
  </div>
161
+ <p class="hint">Tip: use alpha matting when hair, fur, glass, or fine object edges need extra refinement.</p>
 
162
  </section>
163
  </section>
164
  </main>
165
  <script>
166
+ const form = document.getElementById('form');
167
+ const fileInput = document.getElementById('file');
168
+ const dropzone = document.getElementById('dropzone');
169
+ const fileName = document.getElementById('file-name');
170
+ const run = document.getElementById('run');
171
  const status = document.getElementById('status');
172
+ const empty = document.getElementById('empty');
173
+ const wrap = document.getElementById('compare-wrap');
174
+ const before = document.getElementById('before');
175
+ const after = document.getElementById('after');
176
+ const clip = document.getElementById('after-clip');
177
+ const divider = document.getElementById('divider');
178
+ const range = document.getElementById('range');
179
  const download = document.getElementById('download');
180
+ const details = document.getElementById('details');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  const meta = document.getElementById('meta');
182
+ let originalUrl = null;
183
+ let resultUrl = null;
184
+ function setFile(file) { if (!file) return; fileInput.files = (() => { const dt = new DataTransfer(); dt.items.add(file); return dt.files; })(); fileName.textContent = file.name; status.textContent = 'Ready to process.'; status.className = 'status'; if (originalUrl) URL.revokeObjectURL(originalUrl); originalUrl = URL.createObjectURL(file); before.src = originalUrl; }
185
+ fileInput.addEventListener('change', () => setFile(fileInput.files[0]));
186
+ ['dragenter','dragover'].forEach((event) => dropzone.addEventListener(event, (e) => { e.preventDefault(); dropzone.classList.add('drag'); }));
187
+ ['dragleave','drop'].forEach((event) => dropzone.addEventListener(event, (e) => { e.preventDefault(); dropzone.classList.remove('drag'); }));
188
+ dropzone.addEventListener('drop', (e) => setFile(e.dataTransfer.files[0]));
189
+ function updateCompare() { const value = Number(range.value); clip.style.clipPath = `inset(0 ${100 - value}% 0 0)`; divider.style.left = `${value}%`; }
190
+ range.addEventListener('input', updateCompare);
191
  form.addEventListener('submit', async (event) => {
192
  event.preventDefault();
193
+ const file = fileInput.files[0];
194
+ if (!file) { status.textContent = 'Choose an image first.'; status.className = 'status error'; return; }
195
+ run.disabled = true; status.className = 'status'; status.textContent = 'Processing… the first model run may take longer.'; meta.textContent = 'Working on your cutout';
196
+ const outputFormat = document.getElementById('format').value;
197
+ const body = new FormData(); body.append('image', file); body.append('model', document.getElementById('model').value); body.append('format', outputFormat); body.append('background', outputFormat === 'jpg' ? document.getElementById('background').value : 'transparent'); body.append('alpha_matting', document.getElementById('matting').checked ? 'true' : 'false'); body.append('foreground_threshold', document.getElementById('foreground').value); body.append('background_threshold', document.getElementById('threshold').value); body.append('erode_size', document.getElementById('erode').value);
198
+ const apiKey = document.getElementById('api-key').value.trim();
199
  try {
200
+ const headers = apiKey ? { 'X-API-Key': apiKey } : {};
201
+ const response = await fetch('/remove-bg', { method:'POST', headers, body });
202
+ if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `Request failed: ${response.status}`); }
203
+ const blob = await response.blob();
204
+ if (resultUrl) URL.revokeObjectURL(resultUrl); resultUrl = URL.createObjectURL(blob); after.src = resultUrl; download.href = resultUrl; download.classList.add('show'); empty.style.display = 'none'; wrap.classList.add('show'); range.value = 50; updateCompare();
205
+ const model = document.getElementById('model').value; const format = outputFormat.toUpperCase(); details.textContent = `${model} · ${format} ready`; meta.textContent = 'Drag the slider to compare'; status.className = 'status success'; status.textContent = 'Background removed successfully.';
206
+ } catch (error) { status.className = 'status error'; status.textContent = error.message; }
207
+ finally { run.disabled = false; }
 
 
 
 
 
 
 
 
 
 
 
208
  });
209
  </script>
210
  </body>
 
214
 
215
  def _client_key():
216
  forwarded = request.headers.get("X-Forwarded-For", "")
217
+ return forwarded.split(",")[0].strip() or request.remote_addr or "unknown"
218
 
219
 
220
  def _within_rate_limit():
221
  now = time.time()
222
  key = _client_key()
223
  with RATE_LOCK:
224
+ recent = [stamp for stamp in RATE_BUCKETS.get(key, []) if now - stamp < RATE_WINDOW]
225
  if len(recent) >= RATE_LIMIT:
226
  RATE_BUCKETS[key] = recent
227
  return False
 
230
  return True
231
 
232
 
233
+ def _check_api_key():
234
+ configured = os.getenv("API_KEY", "").strip()
235
+ if not configured:
236
+ return True
237
+ supplied = request.headers.get("X-API-Key", "")
238
+ return bool(supplied) and hmac.compare_digest(supplied, configured)
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
 
241
+ def _int_field(name, default, low, high):
 
 
 
 
242
  try:
243
+ value = int(request.form.get(name, default))
244
+ except (TypeError, ValueError):
245
+ raise ValueError(f"{name} must be a number")
246
+ return max(low, min(high, value))
 
247
 
248
 
249
+ def _get_session(model):
250
+ if model not in MODELS:
251
+ raise ValueError("Unknown segmentation model")
252
+ with SESSION_LOCK:
253
+ if model not in SESSIONS:
254
+ SESSIONS[model] = new_session(model)
255
+ return SESSIONS[model]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
 
258
+ def _composite(image, color):
259
+ try:
260
+ rgb = ImageColor.getrgb(color)
261
+ except ValueError as exc:
262
+ raise ValueError("Invalid background color") from exc
263
+ background = Image.new("RGBA", image.size, rgb + (255,))
264
+ background.alpha_composite(image.convert("RGBA"))
265
+ return background
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
 
268
  @app.get("/")
 
270
  return INDEX_HTML
271
 
272
 
273
+ @app.get("/health")
274
+ def health():
275
+ return jsonify({"status": "healthy", "service": "background-studio"}), 200
276
 
277
 
278
+ @app.post("/remove-bg")
279
+ def remove_background():
280
  if not _within_rate_limit():
281
  return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
282
+ if not _check_api_key():
283
+ return jsonify({"error": "Invalid or missing API key"}), 401
284
+ if "image" not in request.files:
285
+ return jsonify({"error": "Choose an image file"}), 400
 
 
 
 
 
 
 
286
 
287
+ uploaded = request.files["image"]
288
+ if not uploaded.filename:
289
+ return jsonify({"error": "No image selected"}), 400
290
+
291
+ model = request.form.get("model", "isnet-general-use")
292
+ output_format = request.form.get("format", "png").lower()
293
+ background = request.form.get("background", "#ffffff")
294
+ alpha_matting = request.form.get("alpha_matting", "false").lower() == "true"
295
+ if output_format not in FORMATS:
296
+ return jsonify({"error": "Unsupported output format"}), 400
297
 
 
 
 
 
 
 
 
 
298
  try:
299
+ image = ImageOps.exif_transpose(Image.open(uploaded.stream))
300
+ image.load()
301
+ if image.width * image.height > 25_000_000:
302
+ return jsonify({"error": "Image dimensions are too large"}), 400
303
+ image = image.convert("RGBA")
304
+ session = _get_session(model)
305
+ output = remove(
306
+ image,
307
+ session=session,
308
+ alpha_matting=alpha_matting,
309
+ alpha_matting_foreground_threshold=_int_field("foreground_threshold", 240, 0, 255),
310
+ alpha_matting_background_threshold=_int_field("background_threshold", 10, 0, 255),
311
+ alpha_matting_erode_size=_int_field("erode_size", 10, 0, 40),
312
+ ).convert("RGBA")
313
+
314
+ if output_format == "jpg" or background.lower() != "transparent":
315
+ output = _composite(output, background if background.lower() != "transparent" else "#ffffff")
316
+ if output_format == "jpg":
317
+ output = output.convert("RGB")
318
+
319
+ result = io.BytesIO()
320
+ if output_format == "jpg":
321
+ output.save(result, format="JPEG", quality=95, optimize=True)
322
+ elif output_format == "webp":
323
+ output.save(result, format="WEBP", quality=95, method=6)
324
+ else:
325
+ output.save(result, format="PNG", optimize=True)
326
+ result.seek(0)
327
+ return send_file(result, mimetype=f"image/{'jpeg' if output_format == 'jpg' else output_format}", as_attachment=True, download_name=f"background-removed.{output_format}")
328
+ except (UnidentifiedImageError, OSError):
329
+ return jsonify({"error": "The uploaded file is not a readable image"}), 400
330
  except ValueError as exc:
331
  return jsonify({"error": str(exc)}), 400
 
 
332
  except Exception:
333
+ app.logger.exception("Background removal failed")
334
+ return jsonify({"error": "Background removal failed. Try a smaller image or another model."}), 500
 
 
 
 
 
335
 
336
 
337
  if __name__ == "__main__":
338
+ app.run(host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  Flask>=3.0,<4
2
- playwright>=1.45,<2
 
 
3
  gunicorn>=22,<24
 
1
  Flask>=3.0,<4
2
+ Pillow>=10,<12
3
+ python-dotenv>=1,<2
4
+ rembg[cpu]>=2.0.50,<3
5
  gunicorn>=22,<24