commit 0b54e4ca06a172b467418a17aa91a435f4daa823 Author: Joseph Date: Mon Jun 29 14:56:04 2026 +0700 Product Image Studio Option 1: tách nền + ghép frame/watermark Flask + rembg (u2net/isnet/birefnet) + Pillow + OpenCV. Tab Tách nền (camera, so sánh 3 mức, polygon crop, cache) + Tab Ghép Frame (danh sách frame/watermark, tỉ lệ object, nền màu/trong suốt). UI responsive. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3e276e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Model cache rembg +.u2net/ + +# Dữ liệu runtime (giữ thư mục, bỏ nội dung) +cache/* +output/* +objects/* +assets/frame/* +assets/watermark/* +!**/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..9392846 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Product Image Studio — Tách nền & Ghép Frame + +Công cụ web xử lý ảnh sản phẩm cho ecom: **tách nền** + **ghép frame/watermark**, chạy local, miễn phí. +Stack: **Flask + rembg (U²-Net/ISNet/BiRefNet ONNX) + Pillow + OpenCV**. + +## Tính năng +**Tab 1 — Tách nền** +- Xóa nền AI (chọn model: u2net / isnet / birefnet), chống cháy sáng giữ chi tiết mép. +- Chụp ảnh trực tiếp từ camera, hoặc upload nhiều ảnh / kéo-thả. +- So sánh 3 mức chất lượng (low/medium/high) chạy song song. +- Vùng chọn đa điểm (polygon) ôm sát object, chỉnh tự do để cắt ngoài vùng. +- Review zoom + lật ảnh gốc; cache cutout để tinh chỉnh không xử lý lại. +- Lưu object (1:1 trong suốt) vào thư viện. + +**Tab 2 — Ghép Frame** +- Danh sách nhiều frame & watermark, chọn là preview đổi ngay. +- Chỉnh tỉ lệ object, vị trí/độ mờ/cỡ watermark, viền. +- Nền: trong suốt hoặc bảng màu / màu tùy chọn. +- Lưu ra file (PNG nếu trong suốt, JPG nếu nền màu). + +UI responsive cho desktop / tablet / mobile. + +## Chạy nhanh +```bash +./run.sh +``` +Script tự tạo virtualenv, cài dependencies và khởi động server tại . +Lần đầu sẽ tải model ONNX (~170MB), sau đó chạy offline. + +### Thủ công +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python server.py +``` + +## Ghi chú +- Apple Silicon: server ép `CPUExecutionProvider` (CoreML biên dịch u2net bị treo). +- `cache/`, `output/`, `objects/`, `assets/` là dữ liệu runtime (đã gitignore). +- Camera trực tiếp cần `localhost` hoặc HTTPS; qua IP LAN sẽ tự dùng camera gốc của máy. diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/frame.png b/assets/frame.png new file mode 100644 index 0000000..9e770fa Binary files /dev/null and b/assets/frame.png differ diff --git a/assets/frame/.gitkeep b/assets/frame/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/watermark.png b/assets/watermark.png new file mode 100644 index 0000000..a99a3ce Binary files /dev/null and b/assets/watermark.png differ diff --git a/assets/watermark/.gitkeep b/assets/watermark/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cache/.gitkeep b/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/objects/.gitkeep b/objects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ab65b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask>=3.0 +rembg>=2.0.50 +pillow>=10.0 +onnxruntime>=1.16 +opencv-python-headless>=4.8 +numpy>=1.24 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..993b874 --- /dev/null +++ b/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Setup + chạy server tách nền / ghép frame (Option 1 - Python/rembg) +# Dùng: ./run.sh +set -euo pipefail +cd "$(dirname "$0")" + +PY="${PYTHON:-python3}" +command -v "$PY" >/dev/null 2>&1 || { echo "✗ Không tìm thấy python3. Cài Python 3.9+ trước."; exit 1; } + +if [ ! -d .venv ]; then + echo "→ Tạo virtualenv (.venv)…" + "$PY" -m venv .venv +fi + +# shellcheck disable=SC1091 +source .venv/bin/activate + +echo "→ Cài / cập nhật dependencies…" +python -m pip install -q --upgrade pip +python -m pip install -q -r requirements.txt + +echo "" +echo "──────────────────────────────────────────────" +echo " Server: http://localhost:8001" +echo " Lần đầu sẽ tự tải model ONNX (~170MB)." +echo " Dừng: Ctrl+C" +echo "──────────────────────────────────────────────" +echo "" +exec python server.py diff --git a/server.py b/server.py new file mode 100644 index 0000000..b65ecbd --- /dev/null +++ b/server.py @@ -0,0 +1,443 @@ +""" +Option 1 — Tách nền + Ghép Frame/Watermark cho ảnh sản phẩm +Stack: Flask + rembg (U^2-Net ONNX, local) + Pillow + OpenCV + +Kiến trúc 2 giai đoạn: + • Tab 1 (Tách nền): xóa nền → object 1:1 trong suốt → LƯU vào thư viện (trả name). + • Tab 2 (Ghép Frame): chọn object + frame + watermark (danh sách) → SAVE render ra file. +""" +import io +import os +import re +import json +import time +import uuid +import hashlib +import threading +from concurrent.futures import ThreadPoolExecutor + +from flask import Flask, request, jsonify, send_from_directory, send_file +from rembg import remove, new_session +from PIL import Image, ImageOps, ImageEnhance, ImageFilter, ImageChops, ImageDraw + +BASE = os.path.dirname(os.path.abspath(__file__)) +ASSETS = os.path.join(BASE, "assets") # assets/frame/*.png, assets/watermark/*.png +OBJECTS = os.path.join(BASE, "objects") # object đã tách nền (thư viện Tab 1) +OUTPUT = os.path.join(BASE, "output") +CACHE = os.path.join(BASE, "cache") # cutout sau remove-bg, tái dùng +for d in (os.path.join(ASSETS, "frame"), os.path.join(ASSETS, "watermark"), OBJECTS, OUTPUT, CACHE): + os.makedirs(d, exist_ok=True) + +app = Flask(__name__, static_folder="static", static_url_path="") + +# ============================ MODEL / SESSION ============================ +SESSIONS = {} +SESSION_LOCK = threading.Lock() +ALLOWED_MODELS = {"u2net", "isnet-general-use", "birefnet-general-lite"} +COMPARE_PRESETS = { + "low": {"model": "u2net", "anti_blowout": False, "recover": 0}, + "medium": {"model": "isnet-general-use", "anti_blowout": True, "recover": 2}, + "high": {"model": "birefnet-general-lite", "anti_blowout": True, "recover": 3}, +} + + +def get_session(model: str): + if model not in ALLOWED_MODELS: + model = "u2net" + with SESSION_LOCK: + if model not in SESSIONS: + print(f"[model] đang khởi tạo '{model}'…", flush=True) + t = time.time() + # Ép CPUExecutionProvider: CoreML trên Apple Silicon biên dịch u2net → treo. + try: + SESSIONS[model] = new_session(model, providers=["CPUExecutionProvider"]) + except TypeError: + os.environ["ONNXRUNTIME_EXECUTION_PROVIDERS"] = "[CPUExecutionProvider]" + SESSIONS[model] = new_session(model) + print(f"[model] '{model}' sẵn sàng sau {time.time() - t:.1f}s", flush=True) + return SESSIONS[model] + + +# ============================ REMOVE BACKGROUND ============================ +def remove_object(src, opt): + """Xóa nền, tối ưu chống cháy sáng để không mất góc/chi tiết mép.""" + rgba = src.convert("RGBA") + session = get_session(opt["model"]) + if not opt["anti_blowout"]: + return remove(rgba, session=session, post_process_mask=True) + work = ImageEnhance.Contrast(src.convert("RGB")).enhance(1.5) + work = ImageEnhance.Brightness(work).enhance(0.85) + mask = remove(work, session=session, only_mask=True, post_process_mask=True) + r = int(opt["recover"]) + if r > 0: + k = r * 2 + 1 + mask = mask.filter(ImageFilter.MaxFilter(k)).filter(ImageFilter.MinFilter(k)) + out = rgba.copy() + out.putalpha(mask) + return out + + +def get_cut(src, img_hash, opt): + """Cutout sau xóa nền, tái dùng cache theo (ảnh + model/anti/recover).""" + key = f"{img_hash}-{opt['model']}-{int(opt['anti_blowout'])}-{opt['recover']}" + path = os.path.join(CACHE, key + ".png") + if os.path.exists(path): + return Image.open(path).convert("RGBA"), True + cut = remove_object(src, opt) + cut.save(path) + return cut, False + + +def fit_square(cut, obj_scale, bg): + """Crop sát object rồi đặt vào canvas vuông 1:1.""" + bbox = cut.getbbox() + if bbox: + cut = cut.crop(bbox) + w, h = cut.size + side = max(1, int(round(max(w, h) / max(0.05, obj_scale)))) + fill = (255, 255, 255, 255) if bg == "white" else (0, 0, 0, 0) + canvas = Image.new("RGBA", (side, side), fill) + canvas.alpha_composite(cut, ((side - w) // 2, (side - h) // 2)) + return canvas + + +def object_polygon(cut, max_pts=48): + """Đường viền (polygon chuẩn hoá 0..1) ôm sát object, suy từ alpha.""" + W, H = cut.size + rect = [[0, 0], [1, 0], [1, 1], [0, 1]] + try: + import cv2 + import numpy as np + except Exception: # noqa: BLE001 + bb = cut.getbbox() + if not bb: + return rect + x0, y0, x1, y1 = bb + return [[x0 / W, y0 / H], [x1 / W, y0 / H], [x1 / W, y1 / H], [x0 / W, y1 / H]] + alpha = np.array(cut.getchannel("A")) + _, binimg = cv2.threshold(alpha, 10, 255, cv2.THRESH_BINARY) + cnts, _ = cv2.findContours(binimg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not cnts: + return rect + c = max(cnts, key=cv2.contourArea) + peri = cv2.arcLength(c, True) + eps = 0.008 * peri + approx = cv2.approxPolyDP(c, eps, True) + while len(approx) > max_pts: + eps *= 1.3 + approx = cv2.approxPolyDP(c, eps, True) + if len(approx) < 3: + return rect + return [[float(p[0][0]) / W, float(p[0][1]) / H] for p in approx] + + +def apply_poly(cut, poly): + """Giữ alpha bên trong polygon (chuẩn hoá), xóa ngoài.""" + W, H = cut.size + pts = [(p[0] * W, p[1] * H) for p in poly] + mask = Image.new("L", (W, H), 0) + ImageDraw.Draw(mask).polygon(pts, fill=255) + out = cut.copy() + out.putalpha(ImageChops.multiply(cut.getchannel("A"), mask)) + return out + + +def position(pos, canvas, item, margin): + cw, ch = canvas + iw, ih = item + table = { + "northwest": (margin, margin), + "north": ((cw - iw) // 2, margin), + "northeast": (cw - iw - margin, margin), + "center": ((cw - iw) // 2, (ch - ih) // 2), + "southwest": (margin, ch - ih - margin), + "south": ((cw - iw) // 2, ch - ih - margin), + "southeast": (cw - iw - margin, ch - ih - margin), + } + return table.get(pos, table["southeast"]) + + +# ============================ TAB 1: TÁCH NỀN ============================ +def make_object(src, img_hash, opt): + """Xóa nền → (polygon crop nếu có) → object vuông 1:1 TRONG SUỐT (không frame/wm).""" + cut, cached = get_cut(src, img_hash, opt) + polygon = object_polygon(cut) + if opt.get("poly"): + cut = apply_poly(cut, opt["poly"]) + canvas = fit_square(cut, 1.0, "transparent") # lưu ôm sát; tỉ lệ chỉnh ở bước ghép frame + out_name = f"{uuid.uuid4().hex[:12]}.png" + canvas.save(os.path.join(OUTPUT, out_name)) + return out_name, cached, polygon + + +def parse_poly(form): + raw = form.get("poly") + if not raw: + return None + try: + pts = [[max(0.0, min(1.0, float(x))), max(0.0, min(1.0, float(y)))] + for x, y in json.loads(raw)] + except (ValueError, TypeError): + return None + return pts if len(pts) >= 3 else None + + +def build_opt(form): + return { + "model": form.get("model", "u2net"), + "anti_blowout": form.get("anti_blowout", "true") == "true", + "recover": int(form.get("recover", 2) or 0), + "poly": parse_poly(form), + } + + +def save_original(data, filename): + ext = os.path.splitext(filename)[1].lower() + if ext not in (".jpg", ".jpeg", ".png", ".webp"): + ext = ".png" + name = f"orig-{uuid.uuid4().hex[:12]}{ext}" + with open(os.path.join(OUTPUT, name), "wb") as fh: + fh.write(data) + return name + + +def run_compare(src, img_hash, filename, original, base_opt): + def run(level): + opt = dict(base_opt, **COMPARE_PRESETS[level]) + t = time.time() + out, cached, polygon = make_object(src, img_hash, opt) + print(f"[compare] {filename} · {level} ✓ {'cache' if cached else f'{time.time()-t:.1f}s'}", flush=True) + return {"name": filename, "output": out, "original": original, + "ok": True, "level": level, "cached": cached, "polygon": polygon} + + levels = ("low", "medium", "high") + with ThreadPoolExecutor(max_workers=3) as ex: + futures = {lv: ex.submit(run, lv) for lv in levels} + out = [] + for lv in levels: + try: + out.append(futures[lv].result()) + except Exception as e: # noqa: BLE001 + print(f"[compare] {filename} · {lv} ✗ {e}", flush=True) + out.append({"name": filename, "ok": False, "level": lv, "error": str(e)}) + return out + + +@app.post("/api/process") +def process(): + opt = build_opt(request.form) + compare = request.form.get("compare", "false") == "true" + files = request.files.getlist("images") + if not files: + return jsonify(error="chưa chọn ảnh"), 400 + + total = len(files) + print(f"[batch] nhận {total} ảnh · {'so sánh 3 mức' if compare else opt['model']}", flush=True) + results = [] + for i, f in enumerate(files, 1): + t = time.time() + try: + data = f.read() + img_hash = hashlib.sha1(data).hexdigest()[:16] + original = save_original(data, f.filename) + src = ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert("RGBA") + if compare: + results.extend(run_compare(src, img_hash, f.filename, original, opt)) + else: + out, cached, polygon = make_object(src, img_hash, opt) + results.append({"name": f.filename, "output": out, "original": original, + "ok": True, "level": None, "cached": cached, "polygon": polygon}) + print(f"[{i}/{total}] {f.filename} ✓ {time.time() - t:.1f}s", flush=True) + except Exception as e: # noqa: BLE001 + print(f"[{i}/{total}] {f.filename} ✗ {e}", flush=True) + results.append({"name": f.filename, "ok": False, "error": str(e)}) + print("[batch] hoàn tất.", flush=True) + return jsonify(results=results) + + +@app.post("/api/recrop") +def recrop(): + """Render lại 1 object với vùng chọn polygon, dùng lại ảnh gốc + cache cutout.""" + original = request.form.get("original", "") + path = os.path.join(OUTPUT, os.path.basename(original)) + if not original or not os.path.exists(path): + return jsonify(error="không tìm thấy ảnh gốc"), 404 + opt = build_opt(request.form) + level = request.form.get("level", "") + if level in COMPARE_PRESETS: + opt.update(COMPARE_PRESETS[level]) + with open(path, "rb") as fh: + data = fh.read() + img_hash = hashlib.sha1(data).hexdigest()[:16] + src = ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert("RGBA") + out, cached, polygon = make_object(src, img_hash, opt) + return jsonify(ok=True, output=out, original=original, level=level or None, + cached=cached, polygon=polygon) + + +# ============================ THƯ VIỆN OBJECT ============================ +def safe_name(name): + name = re.sub(r"[^\w\-]+", "_", (name or "").strip()) + return name[:60] or uuid.uuid4().hex[:8] + + +@app.post("/api/save-object") +def save_object(): + out = os.path.basename(request.form.get("output", "")) + src_path = os.path.join(OUTPUT, out) + if not out or not os.path.exists(src_path): + return jsonify(error="không tìm thấy ảnh đã xử lý"), 404 + name = safe_name(request.form.get("name")) + Image.open(src_path).convert("RGBA").save(os.path.join(OBJECTS, name + ".png")) + print(f"[object] đã lưu '{name}'", flush=True) + return jsonify(ok=True, name=name, url=f"/objects/{name}.png") + + +@app.get("/api/objects") +def list_objects(): + items = [{"name": fn[:-4], "url": f"/objects/{fn}"} + for fn in sorted(os.listdir(OBJECTS)) if fn.endswith(".png")] + return jsonify(items=items) + + +@app.delete("/api/objects/") +def del_object(name): + p = os.path.join(OBJECTS, os.path.basename(name) + ".png") + if os.path.exists(p): + os.remove(p) + return jsonify(ok=True) + + +@app.get("/objects/") +def serve_object(name): + return send_from_directory(OBJECTS, name) + + +# ============================ TAB 2: FRAME / WATERMARK ============================ +def bg_color(bg): + """Trả (r,g,b,255) cho mã hex, hoặc None nếu trong suốt.""" + if not bg or bg == "transparent": + return None + h = bg.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + try: + return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255) + except (ValueError, IndexError): + return None + + +def asset_file(kind, aid): + return os.path.join(ASSETS, kind, f"{os.path.basename(aid)}.png") + + +@app.post("/api/asset") +def add_asset(): + kind = request.form.get("kind") + if kind not in ("frame", "watermark"): + return jsonify(error="kind không hợp lệ"), 400 + f = request.files.get("file") + if not f: + return jsonify(error="thiếu file"), 400 + aid = uuid.uuid4().hex[:12] + Image.open(f.stream).convert("RGBA").save(asset_file(kind, aid)) + return jsonify(ok=True, id=aid, url=f"/api/asset/{kind}/{aid}") + + +@app.get("/api/asset/") +def list_assets(kind): + d = os.path.join(ASSETS, kind) + items = [] + if os.path.isdir(d): + for fn in sorted(os.listdir(d)): + if fn.endswith(".png"): + aid = fn[:-4] + items.append({"id": aid, "url": f"/api/asset/{kind}/{aid}"}) + return jsonify(items=items) + + +@app.get("/api/asset//") +def get_asset(kind, aid): + p = asset_file(kind, aid) + if not os.path.exists(p): + return jsonify(error="chưa có"), 404 + return send_file(p, mimetype="image/png") + + +@app.delete("/api/asset//") +def del_asset(kind, aid): + p = asset_file(kind, aid) + if os.path.exists(p): + os.remove(p) + return jsonify(ok=True) + + +@app.post("/api/compose") +def compose(): + """Ghép object + frame + watermark → render & LƯU file, trả filename.""" + name = os.path.basename(request.form.get("object", "")) + op = os.path.join(OBJECTS, name + ".png") + if not name or not os.path.exists(op): + return jsonify(error="không tìm thấy object"), 404 + obj = Image.open(op).convert("RGBA") + + obj_scale = float(request.form.get("obj_scale", 100)) / 100 + obj = fit_square(obj, obj_scale, "transparent") # đặt lại tỉ lệ object trong khung + + fill = bg_color(request.form.get("bg", "transparent")) # None = trong suốt + border = int(request.form.get("border", 0) or 0) + frame_id = request.form.get("frame", "") + wm_id = request.form.get("watermark", "") + wm_opacity = float(request.form.get("wm_opacity", 60)) / 100 + wm_pos = request.form.get("wm_pos", "southeast") + wm_scale = float(request.form.get("wm_scale", 25)) / 100 + + if fill: + canvas = Image.new("RGBA", obj.size, fill) + canvas.alpha_composite(obj) + else: + canvas = obj.copy() + + if border > 0: + canvas = ImageOps.expand(canvas, border=border, fill=fill or (0, 0, 0, 0)) + + if frame_id and os.path.exists(asset_file("frame", frame_id)): + frame = Image.open(asset_file("frame", frame_id)).convert("RGBA").resize(canvas.size) + canvas.alpha_composite(frame) + + if wm_id and os.path.exists(asset_file("watermark", wm_id)): + wm = Image.open(asset_file("watermark", wm_id)).convert("RGBA") + tw = max(1, int(canvas.width * wm_scale)) + th = max(1, int(wm.height * tw / wm.width)) + wm = wm.resize((tw, th)) + wm.putalpha(wm.split()[3].point(lambda a: int(a * wm_opacity))) + margin = int(canvas.width * 0.03) + x, y = position(wm_pos, canvas.size, wm.size, margin) + canvas.alpha_composite(wm, (x, y)) + + uid = uuid.uuid4().hex[:12] + if fill: # nền đặc → JPG + out_name = f"{uid}.jpg" + canvas.convert("RGB").save(os.path.join(OUTPUT, out_name), quality=92) + else: # trong suốt → PNG + out_name = f"{uid}.png" + canvas.save(os.path.join(OUTPUT, out_name)) + print(f"[compose] {name} + frame={frame_id or '-'} + wm={wm_id or '-'} → {out_name}", flush=True) + return jsonify(ok=True, output=out_name, url=f"/output/{out_name}") + + +@app.get("/") +def index(): + return send_from_directory("static", "index.html") + + +@app.get("/output/") +def output(name): + return send_from_directory(OUTPUT, name) + + +if __name__ == "__main__": + print("→ Option 1 (Python/rembg) đang khởi động…", flush=True) + get_session("u2net") + print("→ Sẵn sàng tại http://localhost:8001", flush=True) + app.run(host="0.0.0.0", port=8001, debug=False, threaded=True) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..27f89cd --- /dev/null +++ b/static/index.html @@ -0,0 +1,688 @@ + + + + + +Studio Ảnh Sản Phẩm · Tách nền & Ghép Frame + + + +
+ +
+
+ + +
+
+ +
+ +
+
+ +
+
1 Chất lượng tách nền
+
+
+ + +
+
+ + +
+
Chống cháy sáng
+
So sánh 3 mức (low/med/high)
+
+
+ + +
+
+
2 Tải ảnh sản phẩm
+
📦

Kéo thả ảnh vào đây — hoặc bấm để chọn

Hỗ trợ nhiều ảnh cùng lúc · JPG / PNG / WebP
+ + +
+
+ + +
+
+
+
+
+
+ + +
+
+ +
+
1 Object đã tách nền
+
+

Chưa có object. Sang tab Tách nền, xử lý rồi bấm Lưu.

+
+ + +
+
2 Xem trước (đổi ngay khi chọn frame / watermark)
+
+
+
Chọn 1 object bên trái để bắt đầu.
+ +
+
+
+ +
+
+
+ + +
+
+
3 Frame (chọn để đổi)
+
+
+
+
Watermark
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
⬚ Trong suốt
+
+ + + + + + + + + +
+
+
+ + +
+
+
+
+
+ + + + + + + + + + +
+ + + +