Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- .dockerignore +17 -0
- Dockerfile +9 -3
- README.md +9 -2
- config.py +20 -3
- main.py +27 -14
- services/job_lifecycle.py +86 -0
- start.sh +15 -4
- tasks.py +8 -3
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.env
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
env/
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.ruff_cache/
|
| 10 |
+
|
| 11 |
+
uploads/
|
| 12 |
+
outputs/
|
| 13 |
+
dump.rdb
|
| 14 |
+
*.rdb
|
| 15 |
+
|
| 16 |
+
.git/
|
| 17 |
+
.vscode/
|
Dockerfile
CHANGED
|
@@ -17,8 +17,14 @@ WORKDIR /app
|
|
| 17 |
COPY requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
|
| 20 |
-
# Step 2:
|
| 21 |
-
ENV HF_HUB_ENABLE_HF_TRANSFER=1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Step 3. Download models for caching
|
| 24 |
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('zeyadcode/ov_owlv2_model', local_dir='./models/ov_owlv2_model')"
|
|
@@ -32,4 +38,4 @@ RUN chmod +x start.sh
|
|
| 32 |
|
| 33 |
EXPOSE 7860
|
| 34 |
|
| 35 |
-
CMD ["./start.sh"]
|
|
|
|
| 17 |
COPY requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
|
| 20 |
+
# Step 2: Runtime defaults for Hugging Face Spaces
|
| 21 |
+
ENV HF_HUB_ENABLE_HF_TRANSFER=1 \
|
| 22 |
+
DEBUG=false \
|
| 23 |
+
RESET_JOBS_ON_STARTUP=true \
|
| 24 |
+
JOB_TTL_SECONDS=3600 \
|
| 25 |
+
JOB_CLEANUP_INTERVAL_SECONDS=300 \
|
| 26 |
+
CELERY_BROKER_URL=redis://localhost:6379/0 \
|
| 27 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
| 28 |
|
| 29 |
# Step 3. Download models for caching
|
| 30 |
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('zeyadcode/ov_owlv2_model', local_dir='./models/ov_owlv2_model')"
|
|
|
|
| 38 |
|
| 39 |
EXPOSE 7860
|
| 40 |
|
| 41 |
+
CMD ["./start.sh"]
|
README.md
CHANGED
|
@@ -9,6 +9,13 @@ pinned: false
|
|
| 9 |
---
|
| 10 |
|
| 11 |
sudo systemctl stop redis-server
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
sudo systemctl stop redis-server
|
| 12 |
+
# Commands
|
| 13 |
+
1. Redis
|
| 14 |
+
> sudo systemctl stop redis-server
|
| 15 |
+
> redis-server --bind 0.0.0.0 --protected-mode no
|
| 16 |
|
| 17 |
+
2. celery worker
|
| 18 |
+
> watchfiles --filter python "cmd /c celery -A tasks worker -P solo" ./tasks.py
|
| 19 |
+
|
| 20 |
+
3. Run main.py
|
| 21 |
+
> uvicorn main:app --reload
|
config.py
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
|
|
| 1 |
from pathlib import Path
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
DEBUG_JOB_ID = "debug-id"
|
| 5 |
|
| 6 |
-
UPLOAD_DIR = Path("uploads")
|
| 7 |
INPUT_IMG_NAME = "input_img.png"
|
| 8 |
DETECTOR_MODEL_PATH = "./models/ov_owlv2_model/ov_owlv2_model.xml"
|
| 9 |
DETECTOR_MODEL_NAME = "google/owlv2-base-patch16-ensemble"
|
|
@@ -16,4 +25,12 @@ DETECTOR_OUT_PATH = "detector_boxes.json"
|
|
| 16 |
|
| 17 |
INPAINTOR_OUT_PATH = "inpainted.png"
|
| 18 |
|
| 19 |
-
INPAINTOR_MODEL_NAME = "stabilityai/stable-diffusion-xl-base-1.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
|
| 5 |
+
def _env_bool(name: str, default: bool) -> bool:
|
| 6 |
+
value = os.getenv(name)
|
| 7 |
+
if value is None:
|
| 8 |
+
return default
|
| 9 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
DEBUG = _env_bool("DEBUG", True)
|
| 13 |
DEBUG_JOB_ID = "debug-id"
|
| 14 |
|
| 15 |
+
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "uploads"))
|
| 16 |
INPUT_IMG_NAME = "input_img.png"
|
| 17 |
DETECTOR_MODEL_PATH = "./models/ov_owlv2_model/ov_owlv2_model.xml"
|
| 18 |
DETECTOR_MODEL_NAME = "google/owlv2-base-patch16-ensemble"
|
|
|
|
| 25 |
|
| 26 |
INPAINTOR_OUT_PATH = "inpainted.png"
|
| 27 |
|
| 28 |
+
INPAINTOR_MODEL_NAME = "stabilityai/stable-diffusion-xl-base-1.0"
|
| 29 |
+
|
| 30 |
+
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
| 31 |
+
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
| 32 |
+
|
| 33 |
+
JOB_METADATA_NAME = "job.json"
|
| 34 |
+
JOB_TTL_SECONDS = int(os.getenv("JOB_TTL_SECONDS", "3600"))
|
| 35 |
+
JOB_CLEANUP_INTERVAL_SECONDS = int(os.getenv("JOB_CLEANUP_INTERVAL_SECONDS", "1500"))
|
| 36 |
+
RESET_JOBS_ON_STARTUP = _env_bool("RESET_JOBS_ON_STARTUP", not DEBUG)
|
main.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastapi import FastAPI, File, UploadFile, Form
|
| 2 |
from fastapi.staticfiles import StaticFiles
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -8,14 +11,28 @@ import tasks as tasks
|
|
| 8 |
from celery.result import AsyncResult
|
| 9 |
import config as config
|
| 10 |
from schemas import SegmentRequest
|
| 11 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
| 19 |
app.add_middleware(
|
| 20 |
CORSMiddleware,
|
| 21 |
allow_origins=["*"],
|
|
@@ -23,9 +40,7 @@ app.add_middleware(
|
|
| 23 |
allow_headers=["*"],
|
| 24 |
)
|
| 25 |
|
| 26 |
-
|
| 27 |
-
os.makedirs("outputs", exist_ok=True) # Good to add this if you use an outputs folder too!
|
| 28 |
-
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
| 29 |
|
| 30 |
|
| 31 |
@app.get("/")
|
|
@@ -36,16 +51,14 @@ def health_check():
|
|
| 36 |
@app.post("/upload")
|
| 37 |
def upload(img: UploadFile = File(...)):
|
| 38 |
job_id = str(uuid.uuid4()) # uuid1 exposes mac address and time, uuid3,5 uses a key value to generate a hash, same input gets same out, uuid4 is random
|
| 39 |
-
job_folder =
|
| 40 |
-
job_folder.mkdir()
|
| 41 |
|
| 42 |
save_path = job_folder / config.INPUT_IMG_NAME
|
| 43 |
with open(save_path, "wb") as buffer:
|
| 44 |
shutil.copyfileobj(img.file, buffer)
|
| 45 |
|
| 46 |
-
return job_id
|
| 47 |
-
|
| 48 |
-
|
| 49 |
@app.get("/job_status/{job_id}")
|
| 50 |
def get_job_status(job_id: str):
|
| 51 |
result = AsyncResult(job_id, app=tasks.celery_app)
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
from fastapi import FastAPI, File, UploadFile, Form
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 11 |
from celery.result import AsyncResult
|
| 12 |
import config as config
|
| 13 |
from schemas import SegmentRequest
|
| 14 |
+
from services import job_lifecycle
|
| 15 |
+
|
| 16 |
+
job_lifecycle.ensure_upload_dir()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@asynccontextmanager
|
| 20 |
+
async def lifespan(app: FastAPI):
|
| 21 |
+
if config.RESET_JOBS_ON_STARTUP:
|
| 22 |
+
job_lifecycle.reset_upload_dir()
|
| 23 |
|
| 24 |
+
cleanup_task = asyncio.create_task(job_lifecycle.cleanup_expired_jobs_forever())
|
| 25 |
+
try:
|
| 26 |
+
yield
|
| 27 |
+
finally:
|
| 28 |
+
cleanup_task.cancel()
|
| 29 |
+
try:
|
| 30 |
+
await cleanup_task
|
| 31 |
+
except asyncio.CancelledError:
|
| 32 |
+
pass
|
| 33 |
|
| 34 |
+
|
| 35 |
+
app = FastAPI(title="Vandalizer", lifespan=lifespan)
|
| 36 |
app.add_middleware(
|
| 37 |
CORSMiddleware,
|
| 38 |
allow_origins=["*"],
|
|
|
|
| 40 |
allow_headers=["*"],
|
| 41 |
)
|
| 42 |
|
| 43 |
+
app.mount("/uploads", StaticFiles(directory=config.UPLOAD_DIR), name="uploads")
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
@app.get("/")
|
|
|
|
| 51 |
@app.post("/upload")
|
| 52 |
def upload(img: UploadFile = File(...)):
|
| 53 |
job_id = str(uuid.uuid4()) # uuid1 exposes mac address and time, uuid3,5 uses a key value to generate a hash, same input gets same out, uuid4 is random
|
| 54 |
+
job_folder = job_lifecycle.create_job_dir(job_id)
|
|
|
|
| 55 |
|
| 56 |
save_path = job_folder / config.INPUT_IMG_NAME
|
| 57 |
with open(save_path, "wb") as buffer:
|
| 58 |
shutil.copyfileobj(img.file, buffer)
|
| 59 |
|
| 60 |
+
return {"job_id": job_id}
|
| 61 |
+
|
|
|
|
| 62 |
@app.get("/job_status/{job_id}")
|
| 63 |
def get_job_status(job_id: str):
|
| 64 |
result = AsyncResult(job_id, app=tasks.celery_app)
|
services/job_lifecycle.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import shutil
|
| 5 |
+
import time
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import config
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def ensure_upload_dir() -> None:
|
| 15 |
+
config.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def reset_upload_dir() -> None:
|
| 19 |
+
if config.UPLOAD_DIR.exists():
|
| 20 |
+
shutil.rmtree(config.UPLOAD_DIR)
|
| 21 |
+
ensure_upload_dir()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_job_dir(job_id: str) -> Path:
|
| 25 |
+
ensure_upload_dir()
|
| 26 |
+
job_path = config.UPLOAD_DIR / job_id
|
| 27 |
+
job_path.mkdir()
|
| 28 |
+
metadata = {
|
| 29 |
+
"job_id": job_id,
|
| 30 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 31 |
+
}
|
| 32 |
+
with open(job_path / config.JOB_METADATA_NAME, "w") as f:
|
| 33 |
+
json.dump(metadata, f, indent=2)
|
| 34 |
+
return job_path
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _created_at_timestamp(job_path: Path) -> float:
|
| 38 |
+
metadata_path = job_path / config.JOB_METADATA_NAME
|
| 39 |
+
if metadata_path.exists():
|
| 40 |
+
try:
|
| 41 |
+
with open(metadata_path) as f:
|
| 42 |
+
metadata = json.load(f)
|
| 43 |
+
created_at = datetime.fromisoformat(metadata["created_at"])
|
| 44 |
+
return created_at.timestamp()
|
| 45 |
+
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
| 46 |
+
logger.warning("Ignoring invalid job metadata at %s", metadata_path)
|
| 47 |
+
|
| 48 |
+
return job_path.stat().st_mtime
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def cleanup_expired_jobs(
|
| 52 |
+
ttl_seconds: int = config.JOB_TTL_SECONDS,
|
| 53 |
+
now: float | None = None,
|
| 54 |
+
) -> int:
|
| 55 |
+
ensure_upload_dir()
|
| 56 |
+
now = time.time() if now is None else now
|
| 57 |
+
deleted_count = 0
|
| 58 |
+
|
| 59 |
+
for job_path in config.UPLOAD_DIR.iterdir():
|
| 60 |
+
if not job_path.is_dir():
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
age_seconds = now - _created_at_timestamp(job_path)
|
| 65 |
+
except FileNotFoundError:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if age_seconds < ttl_seconds:
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
shutil.rmtree(job_path)
|
| 73 |
+
deleted_count += 1
|
| 74 |
+
logger.info("Deleted expired job folder %s", job_path)
|
| 75 |
+
except FileNotFoundError:
|
| 76 |
+
continue
|
| 77 |
+
except OSError:
|
| 78 |
+
logger.exception("Failed to delete expired job folder %s", job_path)
|
| 79 |
+
|
| 80 |
+
return deleted_count
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def cleanup_expired_jobs_forever() -> None:
|
| 84 |
+
while True:
|
| 85 |
+
cleanup_expired_jobs()
|
| 86 |
+
await asyncio.sleep(config.JOB_CLEANUP_INTERVAL_SECONDS)
|
start.sh
CHANGED
|
@@ -1,13 +1,24 @@
|
|
| 1 |
#!/bin/bash
|
| 2 |
|
| 3 |
# Exit immediately if a command exits with a non-zero status
|
| 4 |
-
set -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
echo "Starting Redis server in the background..."
|
| 7 |
-
redis-server --protected-mode no &
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
echo "Starting Celery worker in the background..."
|
| 10 |
-
celery -A tasks worker --loglevel=info &
|
| 11 |
|
| 12 |
echo "Starting FastAPI endpoint in the foreground..."
|
| 13 |
-
exec uvicorn main:app --host 0.0.0.0 --port 7860
|
|
|
|
| 1 |
#!/bin/bash
|
| 2 |
|
| 3 |
# Exit immediately if a command exits with a non-zero status
|
| 4 |
+
set -euo pipefail
|
| 5 |
+
|
| 6 |
+
echo "Resetting runtime job files..."
|
| 7 |
+
mkdir -p uploads outputs /tmp/redis
|
| 8 |
+
rm -rf uploads/* outputs/*
|
| 9 |
+
rm -f dump.rdb /tmp/redis/dump.rdb /var/lib/redis/dump.rdb
|
| 10 |
|
| 11 |
echo "Starting Redis server in the background..."
|
| 12 |
+
redis-server --protected-mode no --save "" --appendonly no --dir /tmp/redis &
|
| 13 |
+
|
| 14 |
+
echo "Waiting for Redis..."
|
| 15 |
+
until redis-cli ping >/dev/null 2>&1; do
|
| 16 |
+
sleep 0.2
|
| 17 |
+
done
|
| 18 |
+
redis-cli FLUSHALL >/dev/null
|
| 19 |
|
| 20 |
echo "Starting Celery worker in the background..."
|
| 21 |
+
celery -A tasks worker --loglevel=info --pool=solo --concurrency=1 &
|
| 22 |
|
| 23 |
echo "Starting FastAPI endpoint in the foreground..."
|
| 24 |
+
exec uvicorn main:app --host 0.0.0.0 --port 7860
|
tasks.py
CHANGED
|
@@ -19,8 +19,13 @@ MODELS: dict[str, Any] = {
|
|
| 19 |
|
| 20 |
celery_app = Celery(
|
| 21 |
"worker",
|
| 22 |
-
broker=
|
| 23 |
-
backend=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
|
@@ -91,7 +96,7 @@ def segment_objects(self, job_id: str, bboxes=None, points=None, point_labels=No
|
|
| 91 |
|
| 92 |
|
| 93 |
@celery_app.task(bind=True)
|
| 94 |
-
def inpaint(self, job_id: str, prompt: str, negative_prompt: str, num_inference_steps: int, guidance_scale: float) -> bool:
|
| 95 |
job_path = config.UPLOAD_DIR / job_id
|
| 96 |
orig_img = Image.open(job_path / config.INPUT_IMG_NAME).convert("RGB")
|
| 97 |
mask_img = Image.open(job_path / config.SEGMENTOR_OUT_BIN_PATH).convert("L")
|
|
|
|
| 19 |
|
| 20 |
celery_app = Celery(
|
| 21 |
"worker",
|
| 22 |
+
broker=config.CELERY_BROKER_URL,
|
| 23 |
+
backend=config.CELERY_RESULT_BACKEND,
|
| 24 |
+
)
|
| 25 |
+
celery_app.conf.update(
|
| 26 |
+
broker_connection_retry_on_startup=True,
|
| 27 |
+
result_expires=config.JOB_TTL_SECONDS,
|
| 28 |
+
task_track_started=True,
|
| 29 |
)
|
| 30 |
|
| 31 |
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
@celery_app.task(bind=True)
|
| 99 |
+
def inpaint(self, job_id: str, prompt: str, negative_prompt: str = "", num_inference_steps: int = 6, guidance_scale: float = 0.0) -> bool:
|
| 100 |
job_path = config.UPLOAD_DIR / job_id
|
| 101 |
orig_img = Image.open(job_path / config.INPUT_IMG_NAME).convert("RGB")
|
| 102 |
mask_img = Image.open(job_path / config.SEGMENTOR_OUT_BIN_PATH).convert("L")
|