zeyadcode commited on
Commit
efff8de
·
verified ·
1 Parent(s): cf69d5a

Sync from GitHub via hub-sync

Browse files
Files changed (8) hide show
  1. .dockerignore +17 -0
  2. Dockerfile +9 -3
  3. README.md +9 -2
  4. config.py +20 -3
  5. main.py +27 -14
  6. services/job_lifecycle.py +86 -0
  7. start.sh +15 -4
  8. 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: Enable fast downloads
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
- watchfiles --filter python "celery -A tasks worker -P solo" ./tasks.py
14
- sudo systemctl stop redis-server && redis-server --bind 0.0.0.0 --protected-mode no
 
 
 
 
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
- DEBUG = True
 
 
 
 
 
 
 
 
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 os
 
 
 
 
 
 
 
 
12
 
13
- if not config.DEBUG:
14
- if config.UPLOAD_DIR.exists():
15
- shutil.rmtree(config.UPLOAD_DIR)
16
- config.UPLOAD_DIR.mkdir(exist_ok=True)
 
 
 
 
 
17
 
18
- app = FastAPI(title="Vandalizer")
 
19
  app.add_middleware(
20
  CORSMiddleware,
21
  allow_origins=["*"],
@@ -23,9 +40,7 @@ app.add_middleware(
23
  allow_headers=["*"],
24
  )
25
 
26
- os.makedirs("uploads", exist_ok=True)
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 = config.UPLOAD_DIR / job_id
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 -e
 
 
 
 
 
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="redis://localhost:6379/0",
23
- backend="redis://localhost:6379/1",
 
 
 
 
 
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")