ilhamdev commited on
Commit
7e96cef
Β·
1 Parent(s): 66c272f

fix(hf): revert to standard demo.launch for ZeroGPU compatibility and add base64 API endpoint

Browse files
Files changed (3) hide show
  1. app.py +89 -68
  2. frontend/src/app/api/remove-bg/route.js +87 -96
  3. requirements.txt +1 -4
app.py CHANGED
@@ -7,39 +7,8 @@ import numpy as np
7
  # pyrefly: ignore [missing-import]
8
  from PIL import Image
9
  from rembg import remove, new_session
10
- import uuid
11
- import os
12
  import io
13
- from fastapi import FastAPI, File, UploadFile
14
- from fastapi.responses import JSONResponse
15
-
16
- # ── Custom FastAPI app (di-mount ke Gradio) ───────────────────────────────────
17
- # Digunakan sebagai endpoint upload untuk Vercel frontend.
18
- # Gradio menerima file input hanya via URL publik β€” bukan base64.
19
- app_fastapi = FastAPI()
20
-
21
- # Folder sementara untuk simpan file upload
22
- UPLOAD_DIR = "/tmp/rembg_uploads"
23
- os.makedirs(UPLOAD_DIR, exist_ok=True)
24
-
25
- @app_fastapi.post("/custom/upload")
26
- async def upload_file(file: UploadFile = File(...)):
27
- """
28
- Upload gambar dan kembalikan URL publik yang bisa dipakai oleh Gradio API.
29
- URL format: https://ilhamdev-rembg.hf.space/file=/tmp/rembg_uploads/{filename}
30
- """
31
- ext = os.path.splitext(file.filename or "image.png")[1] or ".png"
32
- unique_name = f"{uuid.uuid4().hex}{ext}"
33
- save_path = os.path.join(UPLOAD_DIR, unique_name)
34
-
35
- content = await file.read()
36
- with open(save_path, "wb") as f:
37
- f.write(content)
38
-
39
- # URL yang bisa diakses publik via Gradio file serving
40
- file_url = f"https://ilhamdev-rembg.hf.space/file={save_path}"
41
- return JSONResponse({"url": file_url, "path": save_path})
42
-
43
 
44
  # ── Cache model ───────────────────────────────────────────────────────────────
45
  _SESSIONS = {}
@@ -50,17 +19,9 @@ def get_session(model_name: str):
50
  _SESSIONS[model_name] = new_session(model_name)
51
  return _SESSIONS[model_name]
52
 
53
- @spaces.GPU(duration=60)
54
- def remove_bg(img, model_name, alpha_matting):
55
- if img is None:
56
- return None
57
-
58
- if not isinstance(img, Image.Image):
59
- img = Image.fromarray(np.uint8(img))
60
  img = img.convert("RGB")
61
-
62
  session = get_session(model_name)
63
-
64
  kwargs = {}
65
  if alpha_matting:
66
  kwargs.update(
@@ -69,35 +30,95 @@ def remove_bg(img, model_name, alpha_matting):
69
  alpha_matting_background_threshold=10,
70
  alpha_matting_erode_size=10,
71
  )
72
-
73
- # Hasil: PIL mode RGBA (background transparan, RGB subjek UTUH)
74
  return remove(img, session=session, **kwargs)
75
 
76
- demo = gr.Interface(
77
- fn=remove_bg,
78
- inputs=[
79
- gr.Image(type="pil", label="Upload Gambar"),
80
- gr.Dropdown(
81
- choices=[
82
- "birefnet-portrait", # TERBAIK untuk foto orang
83
- "birefnet-general", # TERBAIK untuk objek umum/produk
84
- "isnet-general-use", # Seimbang: cepat & bagus
85
- "u2net_human_seg", # Ringan untuk orang
86
- ],
87
- value="birefnet-portrait",
88
- label="Model AI",
89
- ),
90
- gr.Checkbox(value=False, label="Alpha Matting (haluskan edge rambut)"),
91
- ],
92
- outputs=gr.Image(type="pil", label="Hasil (PNG Transparan)"),
93
- title="Remove Background API",
94
- description="Backend untuk Vercel. Endpoint: /api/remove_bg",
95
- api_name="remove_bg",
96
- )
97
 
98
- # Mount FastAPI custom ke Gradio agar /custom/upload tersedia
99
- app = gr.mount_gradio_app(app_fastapi, demo, path="/")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  if __name__ == "__main__":
102
- import uvicorn
103
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
7
  # pyrefly: ignore [missing-import]
8
  from PIL import Image
9
  from rembg import remove, new_session
10
+ import base64
 
11
  import io
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  # ── Cache model ───────────────────────────────────────────────────────────────
14
  _SESSIONS = {}
 
19
  _SESSIONS[model_name] = new_session(model_name)
20
  return _SESSIONS[model_name]
21
 
22
+ def process_image(img: Image.Image, model_name: str, alpha_matting: bool) -> Image.Image:
 
 
 
 
 
 
23
  img = img.convert("RGB")
 
24
  session = get_session(model_name)
 
25
  kwargs = {}
26
  if alpha_matting:
27
  kwargs.update(
 
30
  alpha_matting_background_threshold=10,
31
  alpha_matting_erode_size=10,
32
  )
 
 
33
  return remove(img, session=session, **kwargs)
34
 
35
+ # ── Handler untuk HuggingFace Web UI ──────────────────────────────────────────
36
+ @spaces.GPU(duration=60)
37
+ def remove_bg_ui(img, model_name, alpha_matting):
38
+ if img is None:
39
+ return None
40
+ if not isinstance(img, Image.Image):
41
+ img = Image.fromarray(np.uint8(img))
42
+ return process_image(img, model_name, alpha_matting)
43
+
44
+ # ── Handler untuk API Eksternal (Vercel / Next.js) ────────────────────────────
45
+ @spaces.GPU(duration=60)
46
+ def remove_bg_api(img_data, model_name, alpha_matting):
47
+ """
48
+ Endpoint API untuk Vercel / frontend.
49
+ Menerima base64 data URL atau objek {path: ...}.
50
+ Mengembalikan data:image/png;base64,... secara langsung tanpa perlu file upload terpisah.
51
+ """
52
+ if not img_data:
53
+ return None
 
 
54
 
55
+ try:
56
+ if isinstance(img_data, str):
57
+ # Jika berupa data URL atau base64 murni
58
+ if "," in img_data:
59
+ img_data = img_data.split(",", 1)[1]
60
+ img_bytes = base64.b64decode(img_data)
61
+ pil_img = Image.open(io.BytesIO(img_bytes))
62
+ elif isinstance(img_data, dict) and "path" in img_data:
63
+ pil_img = Image.open(img_data["path"])
64
+ else:
65
+ return None
66
+
67
+ result_img = process_image(pil_img, model_name, alpha_matting)
68
+
69
+ # Encode hasil ke base64 PNG
70
+ buffered = io.BytesIO()
71
+ result_img.save(buffered, format="PNG")
72
+ b64_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
73
+ return f"data:image/png;base64,{b64_str}"
74
+ except Exception as e:
75
+ print(f"[ERROR] API processing failed: {e}")
76
+ raise e
77
+
78
+ # ── Gradio Blocks Interface ───────────────────────────────────────────────────
79
+ with gr.Blocks(title="Background Remover AI") as demo:
80
+ gr.Markdown("# πŸš€ AI Background Remover")
81
+ gr.Markdown("Hapus background gambar dengan presisi tinggi menggunakan ZeroGPU.")
82
+
83
+ # Tampilan UI untuk pengunjung HuggingFace Space
84
+ with gr.Row():
85
+ with gr.Column():
86
+ ui_input = gr.Image(type="pil", label="Upload Gambar")
87
+ ui_model = gr.Dropdown(
88
+ choices=[
89
+ "birefnet-portrait",
90
+ "birefnet-general",
91
+ "isnet-general-use",
92
+ "u2net_human_seg",
93
+ ],
94
+ value="birefnet-portrait",
95
+ label="Model AI",
96
+ )
97
+ ui_alpha = gr.Checkbox(value=False, label="Alpha Matting (haluskan edge rambut)")
98
+ ui_btn = gr.Button("Hapus Background", variant="primary")
99
+ with gr.Column():
100
+ ui_output = gr.Image(type="pil", label="Hasil (PNG Transparan)")
101
+
102
+ ui_btn.click(
103
+ fn=remove_bg_ui,
104
+ inputs=[ui_input, ui_model, ui_alpha],
105
+ outputs=ui_output,
106
+ )
107
+
108
+ # Komponen API untuk Vercel / endpoint /gradio_api/call/remove_bg
109
+ with gr.Row(visible=False):
110
+ api_input = gr.Textbox(label="Image Base64")
111
+ api_model = gr.Textbox(value="birefnet-portrait", label="Model")
112
+ api_alpha = gr.Checkbox(value=False, label="Alpha Matting")
113
+ api_output = gr.Textbox(label="Result Base64")
114
+ api_btn = gr.Button("API Trigger")
115
+
116
+ api_btn.click(
117
+ fn=remove_bg_api,
118
+ inputs=[api_input, api_model, api_alpha],
119
+ outputs=api_output,
120
+ api_name="remove_bg",
121
+ )
122
 
123
  if __name__ == "__main__":
124
+ demo.launch()
 
frontend/src/app/api/remove-bg/route.js CHANGED
@@ -1,18 +1,12 @@
1
  /**
2
  * API Route: /api/remove-bg
3
  *
4
- * Flow yang benar sesuai dokumentasi Gradio resmi:
5
- * 1. Upload gambar ke /custom/upload (FastAPI endpoint di HF Space)
6
- * β†’ dapat URL publik file di HF server
7
- * 2. POST ke /call/remove_bg dengan { "data": [{"path": url}, model, alpha] }
8
- * β†’ dapat event_id
9
- * 3. GET SSE /call/remove_bg/{event_id}
10
- * β†’ parse event:complete β†’ dapat URL gambar hasil
11
- * 4. Fetch gambar hasil β†’ kembalikan base64 ke browser
12
  */
13
 
14
- const HF_SPACE_URL = process.env.NEXT_PUBLIC_HF_SPACE_URL || 'https://ilhamdev-rembg.hf.space'
15
- const TIMEOUT_MS = 90_000
16
 
17
  export async function POST(request) {
18
  try {
@@ -22,52 +16,60 @@ export async function POST(request) {
22
  const alphaMatting = formData.get('alpha_matting') === 'true'
23
 
24
  if (!imageFile) {
25
- return Response.json({ error: 'No image provided' }, { status: 400 })
26
  }
27
 
28
- // ── STEP 1: Upload gambar ke endpoint FastAPI custom di HF Space ──────
29
- const uploadForm = new FormData()
30
- const blob = new Blob([await imageFile.arrayBuffer()], { type: imageFile.type || 'image/png' })
31
- uploadForm.append('file', blob, imageFile.name || 'image.png')
 
32
 
33
- const uploadRes = await fetch(`${HF_SPACE_URL}/custom/upload`, {
34
- method: 'POST',
35
- body: uploadForm,
36
- })
37
-
38
- if (!uploadRes.ok) {
39
- const errText = await uploadRes.text()
40
- console.error('[Upload Error]', uploadRes.status, errText.slice(0, 200))
41
- return Response.json(
42
- { error: `Gagal upload gambar: ${uploadRes.status} β€” ${errText.slice(0, 100)}` },
43
- { status: 502 }
44
- )
45
  }
46
 
47
- const { url: imageUrl } = await uploadRes.json()
48
- console.log('[Upload OK] imageUrl:', imageUrl)
49
-
50
- // ── STEP 2: POST ke /call/remove_bg dengan URL file ────────────────────
51
- // Format sesuai docs Gradio: file input = { "path": "https://..." }
52
- const callPayload = {
53
- data: [
54
- { path: imageUrl }, // URL publik, bukan base64
55
- modelName,
56
- alphaMatting,
57
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
 
60
- const callRes = await fetch(`${HF_SPACE_URL}/call/remove_bg`, {
61
- method: 'POST',
62
- headers: { 'Content-Type': 'application/json' },
63
- body: JSON.stringify(callPayload),
64
- })
65
-
66
- if (!callRes.ok) {
67
- const errText = await callRes.text()
68
- console.error('[Call Error]', callRes.status, errText.slice(0, 300))
69
  return Response.json(
70
- { error: `Gagal panggil HuggingFace API: ${callRes.status} β€” ${errText.slice(0, 150)}` },
 
 
71
  { status: 502 }
72
  )
73
  }
@@ -78,13 +80,15 @@ export async function POST(request) {
78
  }
79
  console.log('[Call OK] event_id:', event_id)
80
 
81
- // ── STEP 3: Baca SSE stream hasil ─────────────────────────────────────
 
82
  const sseController = new AbortController()
83
  const sseTimer = setTimeout(() => sseController.abort(), TIMEOUT_MS)
84
 
85
  let sseRes
86
  try {
87
- sseRes = await fetch(`${HF_SPACE_URL}/call/remove_bg/${event_id}`, {
 
88
  signal: sseController.signal,
89
  })
90
  } finally {
@@ -93,16 +97,15 @@ export async function POST(request) {
93
 
94
  if (!sseRes.ok) {
95
  return Response.json(
96
- { error: `Gagal baca SSE hasil: ${sseRes.status}` },
97
  { status: 502 }
98
  )
99
  }
100
 
101
  const sseText = await sseRes.text()
102
- console.log('[SSE Raw first 500]', sseText.slice(0, 500))
103
 
104
- // Parse SSE β€” cari event "complete"
105
- let resultData = null
106
  let errorMsg = null
107
  const lines = sseText.split('\n')
108
  let currentEvent = ''
@@ -121,77 +124,65 @@ export async function POST(request) {
121
  try {
122
  const parsed = JSON.parse(raw)
123
  if (Array.isArray(parsed) && parsed.length > 0) {
124
- resultData = parsed[0]
125
  }
126
  } catch {
127
- console.error('[SSE parse fail]', raw.slice(0, 100))
128
  }
129
  }
130
  }
131
  }
132
 
133
  if (errorMsg) {
134
- return Response.json({ error: `Gradio error: ${errorMsg}` }, { status: 502 })
135
  }
136
 
137
- if (!resultData) {
138
  return Response.json(
139
- { error: 'Tidak ada data hasil. Coba lagi β€” ZeroGPU mungkin cold start.' },
140
  { status: 502 }
141
  )
142
  }
143
 
144
- // ── STEP 4: Fetch gambar hasil β†’ kembalikan base64 ────────────────────
145
- // resultData sesuai docs: { path, url, orig_name, meta }
146
- // url format: "https://ilhamdev-rembg.hf.space/c/file=/tmp/gradio/xxx/image.png"
147
- let imgUrl = resultData.url || resultData.path
148
- console.log('[Result data]', JSON.stringify(resultData).slice(0, 200))
149
-
150
- if (!imgUrl) {
151
- return Response.json({ error: 'Tidak ada URL gambar hasil dari Gradio' }, { status: 502 })
152
- }
153
-
154
- // Jika path relatif, buat URL lengkap
155
- if (!imgUrl.startsWith('http') && !imgUrl.startsWith('data:')) {
156
- // Format Gradio untuk serve file: /file=/tmp/gradio/xxx/image.png
157
- imgUrl = `${HF_SPACE_URL}/file=${imgUrl}`
158
- }
159
-
160
- const imgRes = await fetch(imgUrl)
161
- if (!imgRes.ok) {
162
- // Coba format alternatif dengan /c/ prefix (Gradio CDN)
163
- const altUrl = `${HF_SPACE_URL}/c/file=${resultData.path || ''}`
164
- console.log('[Retry alt URL]', altUrl)
165
- const altRes = await fetch(altUrl)
166
- if (!altRes.ok) {
167
- return Response.json(
168
- { error: `Gagal fetch gambar hasil: ${imgRes.status}` },
169
- { status: 502 }
170
- )
171
  }
172
- const buf = await altRes.arrayBuffer()
173
  return Response.json({
174
  success: true,
175
- image: `data:image/png;base64,${Buffer.from(buf).toString('base64')}`,
176
  })
177
  }
178
 
179
- const imgBuf = await imgRes.arrayBuffer()
180
- const mime = imgRes.headers.get('content-type') || 'image/png'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- return Response.json({
183
- success: true,
184
- image: `data:${mime};base64,${Buffer.from(imgBuf).toString('base64')}`,
185
- })
186
 
187
  } catch (err) {
188
  if (err.name === 'AbortError') {
189
  return Response.json(
190
- { error: 'Timeout 90 detik. ZeroGPU sedang cold start, coba lagi dalam 30 detik.' },
191
  { status: 504 }
192
  )
193
  }
194
- console.error('[API Error]', err)
195
  return Response.json({ error: err.message }, { status: 500 })
196
  }
197
  }
 
1
  /**
2
  * API Route: /api/remove-bg
3
  *
4
+ * Mengirim gambar langsung sebagai base64 ke HuggingFace Gradio Space (ZeroGPU).
5
+ * Tidak memerlukan endpoint /upload terpisah, menghindari masalah 404/405 di Hugging Face SSR proxy.
 
 
 
 
 
 
6
  */
7
 
8
+ const HF_SPACE_URL = (process.env.NEXT_PUBLIC_HF_SPACE_URL || 'https://ilhamdev-rembg.hf.space').replace(/\/+$/, '')
9
+ const TIMEOUT_MS = 90_000 // 90 detik untuk toleransi ZeroGPU cold start
10
 
11
  export async function POST(request) {
12
  try {
 
16
  const alphaMatting = formData.get('alpha_matting') === 'true'
17
 
18
  if (!imageFile) {
19
+ return Response.json({ error: 'Tidak ada file gambar yang dikirim' }, { status: 400 })
20
  }
21
 
22
+ // ── STEP 1: Konversi file gambar ke Base64 Data URL ────────────────────
23
+ const arrayBuffer = await imageFile.arrayBuffer()
24
+ const base64 = Buffer.from(arrayBuffer).toString('base64')
25
+ const mimeType = imageFile.type || 'image/png'
26
+ const dataUrl = `data:${mimeType};base64,${base64}`
27
 
28
+ const payload = {
29
+ data: [dataUrl, modelName, alphaMatting],
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
+ // ── STEP 2: POST ke Gradio API Endpoint ────────────────────────────────
33
+ // Coba path standar Hugging Face (/gradio_api/call/remove_bg), lalu fallback ke (/call/remove_bg)
34
+ const candidateEndpoints = [
35
+ `${HF_SPACE_URL}/gradio_api/call/remove_bg`,
36
+ `${HF_SPACE_URL}/call/remove_bg`,
37
+ ]
38
+
39
+ let callRes = null
40
+ let usedEndpoint = ''
41
+ let lastErrorText = ''
42
+
43
+ for (const endpoint of candidateEndpoints) {
44
+ try {
45
+ console.log('[Connecting] Mengirim request ke:', endpoint)
46
+ const res = await fetch(endpoint, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'Accept': 'application/json',
51
+ },
52
+ body: JSON.stringify(payload),
53
+ })
54
+
55
+ if (res.ok) {
56
+ callRes = res
57
+ usedEndpoint = endpoint
58
+ break
59
+ } else {
60
+ lastErrorText = await res.text()
61
+ console.warn(`[Endpoint ${res.status}] ${endpoint}:`, lastErrorText.slice(0, 120))
62
+ }
63
+ } catch (err) {
64
+ console.warn(`[Fetch Fail] ${endpoint}:`, err.message)
65
+ }
66
  }
67
 
68
+ if (!callRes) {
 
 
 
 
 
 
 
 
69
  return Response.json(
70
+ {
71
+ error: `Gagal memanggil backend HuggingFace. Status server mungkin sedang starting/rebuilding: ${lastErrorText.slice(0, 100) || 'Connection failed'}`,
72
+ },
73
  { status: 502 }
74
  )
75
  }
 
80
  }
81
  console.log('[Call OK] event_id:', event_id)
82
 
83
+ // ── STEP 3: Baca SSE Stream untuk mengambil hasil ──────────────────────
84
+ const sseUrl = `${usedEndpoint}/${event_id}`
85
  const sseController = new AbortController()
86
  const sseTimer = setTimeout(() => sseController.abort(), TIMEOUT_MS)
87
 
88
  let sseRes
89
  try {
90
+ sseRes = await fetch(sseUrl, {
91
+ headers: { Accept: 'text/event-stream' },
92
  signal: sseController.signal,
93
  })
94
  } finally {
 
97
 
98
  if (!sseRes.ok) {
99
  return Response.json(
100
+ { error: `Gagal membaca SSE status dari HuggingFace: ${sseRes.status}` },
101
  { status: 502 }
102
  )
103
  }
104
 
105
  const sseText = await sseRes.text()
106
+ console.log('[SSE Status received, length:]', sseText.length)
107
 
108
+ let resultOutput = null
 
109
  let errorMsg = null
110
  const lines = sseText.split('\n')
111
  let currentEvent = ''
 
124
  try {
125
  const parsed = JSON.parse(raw)
126
  if (Array.isArray(parsed) && parsed.length > 0) {
127
+ resultOutput = parsed[0]
128
  }
129
  } catch {
130
+ console.error('[SSE parse error]', raw.slice(0, 100))
131
  }
132
  }
133
  }
134
  }
135
 
136
  if (errorMsg) {
137
+ return Response.json({ error: `Gradio inference error: ${errorMsg}` }, { status: 502 })
138
  }
139
 
140
+ if (!resultOutput) {
141
  return Response.json(
142
+ { error: 'ZeroGPU belum selesai atau sedang antre. Silakan coba kembali sesaat lagi.' },
143
  { status: 502 }
144
  )
145
  }
146
 
147
+ // ── STEP 4: Format Kembalian Gambar ───────────────────────────────────
148
+ // Jika resultOutput adalah base64 data URL langsung (sesuai fungsi remove_bg_api kita)
149
+ if (typeof resultOutput === 'string') {
150
+ if (resultOutput.startsWith('data:')) {
151
+ return Response.json({ success: true, image: resultOutput })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
 
153
  return Response.json({
154
  success: true,
155
+ image: `data:image/png;base64,${resultOutput}`,
156
  })
157
  }
158
 
159
+ // Fallback jika berupa objek file Gradio { path, url }
160
+ let imgUrl = resultOutput.url || resultOutput.path
161
+ if (imgUrl) {
162
+ if (!imgUrl.startsWith('http') && !imgUrl.startsWith('data:')) {
163
+ imgUrl = `${HF_SPACE_URL}/gradio_api/file=${imgUrl}`
164
+ }
165
+ const fileRes = await fetch(imgUrl)
166
+ if (fileRes.ok) {
167
+ const buf = await fileRes.arrayBuffer()
168
+ const mime = fileRes.headers.get('content-type') || 'image/png'
169
+ return Response.json({
170
+ success: true,
171
+ image: `data:${mime};base64,${Buffer.from(buf).toString('base64')}`,
172
+ })
173
+ }
174
+ }
175
 
176
+ return Response.json({ error: 'Format output dari HuggingFace tidak dikenali' }, { status: 502 })
 
 
 
177
 
178
  } catch (err) {
179
  if (err.name === 'AbortError') {
180
  return Response.json(
181
+ { error: 'Timeout 90 detik. Model ZeroGPU sedang cold start, silakan coba lagi.' },
182
  { status: 504 }
183
  )
184
  }
185
+ console.error('[API Handler Error]', err)
186
  return Response.json({ error: err.message }, { status: 500 })
187
  }
188
  }
requirements.txt CHANGED
@@ -3,7 +3,4 @@ spaces
3
  pillow
4
  numpy
5
  rembg[cpu]
6
- pymatting
7
- fastapi
8
- uvicorn
9
- python-multipart
 
3
  pillow
4
  numpy
5
  rembg[cpu]
6
+ pymatting