image-processor-pro / webapp.py
divakar-rajodiya
Image Processor Pro web app
6d8fa62
Raw
History Blame Contribute Delete
10.6 kB
"""Local web UI for Image Processor Pro.
A thin Flask front-end over the EXISTING pipeline. It does not change any
processing logic — it builds a Config and calls ImagePipeline.process_many()
exactly like the CLI (main.py) does, then shows the results in the browser.
Run:
.venv/bin/python webapp.py # http://127.0.0.1:5001
.venv/bin/python webapp.py --port 8080
"""
from __future__ import annotations
import argparse
import threading
from pathlib import Path
from flask import Flask, jsonify, render_template_string, request, send_from_directory
from config import Config
from pipeline import ImagePipeline
from utils import human_size, setup_logging
app = Flask(__name__)
OUTPUT_ROOT = Path("output").resolve()
# One pipeline per (remove_watermark, engine) combo so the LaMa model is loaded
# once and reused across requests. Batches are serialised — this is a local,
# single-user tool and it keeps us clear of any shared-state questions.
_pipelines: dict[tuple[bool, str], ImagePipeline] = {}
_pipelines_lock = threading.Lock()
_process_lock = threading.Lock()
def get_pipeline(remove_watermark: bool, engine: str) -> ImagePipeline:
key = (remove_watermark, engine)
with _pipelines_lock:
pipeline = _pipelines.get(key)
if pipeline is None:
config = Config()
config.remove_watermark = remove_watermark
config.watermark_engine = engine
pipeline = ImagePipeline(config)
_pipelines[key] = pipeline
return pipeline
def _output_url(output_path: Path) -> str | None:
try:
rel = output_path.resolve().relative_to(OUTPUT_ROOT)
except (ValueError, OSError):
return None
return "/output/" + str(rel).replace("\\", "/")
@app.route("/")
def index() -> str:
return render_template_string(PAGE)
@app.route("/output/<path:subpath>")
def serve_output(subpath: str):
# send_from_directory rejects path traversal outside OUTPUT_ROOT.
return send_from_directory(OUTPUT_ROOT, subpath)
@app.route("/process", methods=["POST"])
def process():
data = request.get_json(silent=True) or {}
raw = data.get("urls", "")
urls = [line.strip() for line in raw.splitlines() if line.strip()]
if not urls:
return jsonify(error="Please paste at least one image URL."), 400
remove_watermark = bool(data.get("remove_watermark", True))
engine = "classical" if data.get("engine") == "classical" else "lama"
pipeline = get_pipeline(remove_watermark, engine)
with _process_lock: # one batch at a time
results = pipeline.process_many(urls)
by_url: dict[str, object] = {}
for r in results:
by_url.setdefault(r.url, r)
payload = []
for url in urls:
r = by_url.get(url)
if r is None:
payload.append(dict(url=url, success=False, error="No result returned."))
continue
dims = f"{r.final_dimensions[0]}×{r.final_dimensions[1]}" if r.final_dimensions else None
payload.append(dict(
url=url,
success=r.success,
output_url=_output_url(r.output_path) if r.output_path else None,
dimensions=dims,
size=human_size(r.final_size_bytes) if r.success else None,
wm_seconds=round(r.watermark_seconds, 2),
error=r.error,
))
succeeded = sum(1 for p in payload if p["success"])
return jsonify(results=payload, succeeded=succeeded, failed=len(payload) - succeeded,
engine=engine, remove_watermark=remove_watermark)
PAGE = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Image Processor Pro</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; background: #f6f7f9; color: #1c1d22; }
header { background: linear-gradient(135deg,#4f46e5,#7c3aed); color:#fff; padding: 22px 28px; }
header h1 { margin: 0; font-size: 20px; font-weight: 650; }
header p { margin: 6px 0 0; opacity: .85; font-size: 13px; }
main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 64px; }
.panel { background:#fff; border:1px solid #e6e7eb; border-radius:14px; padding:20px;
box-shadow:0 1px 3px rgba(0,0,0,.05); }
textarea { width:100%; min-height:150px; resize:vertical; padding:12px 14px; font:13px/1.5 ui-monospace,
SFMono-Regular,Menlo,monospace; border:1px solid #d6d8df; border-radius:10px; background:#fbfbfc; color:#1c1d22;}
.row { display:flex; flex-wrap:wrap; gap:18px; align-items:center; margin-top:14px; }
label.opt { display:flex; align-items:center; gap:7px; font-size:14px; cursor:pointer; }
select { padding:7px 10px; border-radius:8px; border:1px solid #d6d8df; background:#fff; font-size:14px; }
button { margin-left:auto; background:#4f46e5; color:#fff; border:0; border-radius:10px;
padding:11px 22px; font-size:15px; font-weight:600; cursor:pointer; }
button:disabled { opacity:.55; cursor:not-allowed; }
.hint { color:#6b7280; font-size:12.5px; margin:4px 2px 0; }
#status { margin:20px 2px 0; font-size:14px; min-height:20px; }
.spinner { display:inline-block; width:15px; height:15px; border:2px solid #c7c9d1;
border-top-color:#4f46e5; border-radius:50%; animation:spin .8s linear infinite; vertical-align:-3px; margin-right:7px;}
@keyframes spin { to { transform: rotate(360deg);} }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; margin-top:18px; }
.card { background:#fff; border:1px solid #e6e7eb; border-radius:14px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,.05); }
.card .imgs { display:grid; grid-template-columns:1fr 1fr; gap:1px; background:#eceef2; }
.card figure { margin:0; background:#fff; }
.card figcaption { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:#8a8f9a;
text-align:center; padding:6px 0 2px; }
.card img { width:100%; aspect-ratio:1/1; object-fit:contain; display:block; background:#fafbfc; }
.card .meta { padding:11px 13px 13px; }
.card .u { font-size:11.5px; color:#6b7280; word-break:break-all; line-height:1.35; }
.card .stat { margin-top:8px; font-size:13px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.ok { color:#15803d; font-weight:600; } .bad { color:#b91c1c; font-weight:600; }
.dl { margin-left:auto; font-size:12.5px; color:#4f46e5; text-decoration:none; font-weight:600; }
.err { padding:14px; font-size:13px; color:#b91c1c; background:#fef2f2; }
.summary { margin:6px 2px 0; font-size:14px; font-weight:600; }
</style>
</head>
<body>
<header>
<h1>Image Processor Pro</h1>
<p>Paste image URLs (one per line) — they’re downloaded, watermark-removed, and saved as optimized JPEGs.</p>
</header>
<main>
<div class="panel">
<textarea id="urls" placeholder="https://images.meesho.com/images/products/.../xxxxx_512.avif&#10;https://images.meesho.com/images/products/.../yyyyy_512.avif&#10;... one URL per line, add as many as you like"></textarea>
<p class="hint">Tip: paste 10+ URLs at once — they’re processed together.</p>
<div class="row">
<label class="opt"><input type="checkbox" id="rmwm" checked> Remove watermark</label>
<button id="go">Process</button>
</div>
</div>
<div id="status"></div>
<div class="summary" id="summary"></div>
<div class="grid" id="grid"></div>
</main>
<script>
const $ = id => document.getElementById(id);
const go = $("go"), statusEl = $("status"), grid = $("grid"), summary = $("summary");
function escapeHtml(s){ return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function card(r){
const u = escapeHtml(r.url);
if(!r.success){
return `<div class="card"><div class="err"><b>Failed</b><br>${escapeHtml(r.error||'unknown error')}</div>
<div class="meta"><div class="u">${u}</div></div></div>`;
}
return `<div class="card">
<div class="imgs">
<figure><figcaption>Original</figcaption><img loading="lazy" src="${u}" alt="original"></figure>
<figure><figcaption>Processed</figcaption><img loading="lazy" src="${r.output_url}" alt="processed"></figure>
</div>
<div class="meta">
<div class="u">${u}</div>
<div class="stat"><span class="ok">✓ done</span>
<span>${r.dimensions||''} · ${r.size||''}</span>
<span style="color:#9aa0ac">wm ${r.wm_seconds}s</span>
<a class="dl" href="${r.output_url}" download>Download</a></div>
</div></div>`;
}
async function run(){
const urls = $("urls").value;
const list = urls.split("\n").map(s=>s.trim()).filter(Boolean);
if(!list.length){ statusEl.textContent = "Paste at least one URL first."; return; }
go.disabled = true; grid.innerHTML = ""; summary.textContent = "";
statusEl.innerHTML = `<span class="spinner"></span>Processing ${list.length} image(s)… LaMa runs ~2–6s each, please wait.`;
try{
const resp = await fetch("/process", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ urls, remove_watermark:$("rmwm").checked, engine:"lama" })
});
const data = await resp.json();
if(!resp.ok){ statusEl.textContent = data.error || "Something went wrong."; return; }
statusEl.textContent = "";
summary.textContent = `${data.succeeded} succeeded` + (data.failed ? `, ${data.failed} failed` : "") +
(data.remove_watermark ? "" : " · watermark removal off");
grid.innerHTML = data.results.map(card).join("");
}catch(e){
statusEl.textContent = "Request failed: " + e.message;
}finally{
go.disabled = false;
}
}
go.addEventListener("click", run);
$("urls").addEventListener("keydown", e => { if((e.metaKey||e.ctrlKey) && e.key==="Enter") run(); });
</script>
</body>
</html>
"""
def main() -> None:
parser = argparse.ArgumentParser(description="Local web UI for Image Processor Pro.")
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default 127.0.0.1, local only).")
parser.add_argument("--port", type=int, default=5001, help="Port (default 5001).")
args = parser.parse_args()
setup_logging(Path("logs"), verbose=False)
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
print(f"\n Image Processor Pro UI -> http://{args.host}:{args.port}\n")
app.run(host=args.host, port=args.port, threaded=True)
if __name__ == "__main__":
main()