Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
lint:
name: Lint & compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: Ruff (lint)
run: ruff check . || true # advisory for now; tighten once the tree is clean
- name: Byte-compile all modules
run: python -m compileall -q .

secrets:
name: Secret scan
runs-on: ubuntu-latest
# Blocking: the only known literal (the dev API-key fallback) is allowlisted
# in .gitleaks.toml, so any *new* secret fails the build.
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Use the gitleaks binary rather than the Action: the Action requires a
# paid license for organization repos, the binary is free and equivalent.
- name: Install gitleaks
run: |
VERSION=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| tar -xz -C /tmp gitleaks
sudo mv /tmp/gitleaks /usr/local/bin/gitleaks
- name: Scan git history for secrets
run: gitleaks detect --source . --redact --no-banner

smoke:
name: Pipeline smoke test (CPU)
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Cache Hugging Face models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: hf-${{ hashFiles('model_registry.py') }}
- name: Install CPU torch (keep CI off CUDA wheels)
run: |
pip install --upgrade pip
pip install torch==2.13.0 torchvision==0.28.0 torchaudio==2.11.0 \
--index-url https://download.pytorch.org/whl/cpu
- name: Install pinned dependencies
run: pip install -r requirements.txt
- name: Run end-to-end smoke test
env:
HF_HOME: ~/.cache/huggingface
run: python scripts/smoke_test.py
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ runs/*
*.pt
__pycache__/
__pycache__/*

# Local dependency-verification artifact (CPU freeze); not for the repo
.verified-freeze-cpu.txt
19 changes: 19 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# gitleaks configuration for no-code-transformers.
#
# Extends the default rule set and allowlists the single known dev-only value
# so the secret scan can run as a blocking CI gate. Any real secret will still
# fail the scan.

title = "no-code-transformers gitleaks config"

[extend]
useDefault = true

[allowlist]
description = "Dev-only, non-production values"
regexes = [
# Insecure development fallback for the API key. Real deployments set the
# API_KEY env var; the service warns loudly when this fallback is used.
# This literal is not a live secret.
'''aphrc-secret-key-123''',
]
25 changes: 16 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# no-code-deeplearning-prod/Dockerfile

# Use NVIDIA's official CUDA base image
FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu22.04
# NVIDIA CUDA base. Ubuntu 24.04 ships Python 3.12, which the pinned stack in
# requirements.txt requires (numpy 2.4 / pandas 3.0 need Python >= 3.11).
FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04

ENV DEBIAN_FRONTEND=noninteractive

# Install Python 3.10 and pip
# Python 3.12 + the OpenCV/rendering shared libs pulled in by opencv/albumentations.
RUN apt-get update && \
apt-get install -y \
python3.10 \
python3.12 \
python3.12-venv \
python3-pip \
libgl1 \
libglib2.0-0 \
Expand All @@ -18,16 +20,21 @@ RUN apt-get update && \
libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*

RUN ln -sf /usr/bin/python3.10 /usr/bin/python && \
ln -sf /usr/bin/pip3 /usr/bin/pip
# Use an isolated virtualenv. On Ubuntu 24.04 the system Python is
# externally-managed (PEP 668), so installing into it needs a venv (or
# --break-system-packages); a venv is cleaner and keeps the image reproducible.
RUN python3.12 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

WORKDIR /app

# Copy requirements first for layer caching
COPY requirements.txt .

# Install Python dependencies (with CUDA torch)
RUN pip install --no-cache-dir -r requirements.txt
# Install the pinned dependencies. On this CUDA base image, PyPI serves the
# CUDA build of the pinned torch version.
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt

# Copy the entire DL service codebase
COPY . .
COPY . .
66 changes: 22 additions & 44 deletions fastapi_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,32 @@
# --- 4. Mount the StaticFiles directory to the *new* shared path ---
app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs")

# CORS: comma-separated origins via ALLOWED_ORIGINS (default "*" for dev).
# Note: the browser CORS spec forbids credentials together with a "*" origin,
# so credentials are only enabled when an explicit origin allow-list is set.
_allowed_origins = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",") if o.strip()]
_allow_credentials = _allowed_origins != ["*"]

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=_allowed_origins,
allow_credentials=_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)

# --- Security ---
API_KEY = os.getenv("API_KEY", "aphrc-secret-key-123") # Default for dev
# API key MUST be provided via the API_KEY env var in any shared/production
# deployment. The insecure development fallback below is only used when unset,
# and a loud warning is emitted so it is never silently relied upon.
API_KEY = os.getenv("API_KEY")
if not API_KEY:
API_KEY = "aphrc-secret-key-123" # dev-only fallback
print(
"WARNING: API_KEY env var is not set — using the insecure default dev key. "
"Set API_KEY before exposing this service.",
file=sys.stderr,
)

async def verify_api_key(x_api_key: str = Header(None)):
"""
Expand Down Expand Up @@ -347,7 +363,6 @@ async def start_object_detection_training(
"--weight_decay", str(weight_decay_hf),
"--gradient_accumulation_steps", str(gradient_accumulation_steps),
"--early_stopping_threshold", str(early_stopping_threshold),
"--early_stopping_threshold", str(early_stopping_threshold),
"--eval_batch_size", str(eval_batch_size),
"--optimizer", optimizer_hf,
"--scheduler", scheduler_hf,
Expand Down Expand Up @@ -778,15 +793,11 @@ async def cancel_job(job_id: str):
job_store.update_job_status(job_id, "cancelled")

return {"message": f"Job {job_id} cancelled (PID {pid} terminated)", "status": "cancelled"}

except ProcessLookupError:
# Process already gone
job_store.update_job_status(job_id, "cancelled") # or failed/completed?
return {"message": "Job process not found, marked as cancelled", "status": "cancelled"}

except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to cancel job: {e}")


except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to cancel job: {e}")
Expand Down Expand Up @@ -990,39 +1001,6 @@ async def run_object_detection_inference(
return JSONResponse(status_code=500, content={"error": str(e)})


@app.post("/inference/asr", summary="Run ASR Inference")
async def run_asr_inference_endpoint(
model_checkpoint: str = Form(..., description="Path to the model checkpoint"),
audio: UploadFile = File(..., description="The audio file to transcribe.")
):
"""
Runs the 'asr_inference.py' script on an uploaded audio file
and returns the transcription as a string.
"""
if not job_store.is_valid_checkpoint_path(model_checkpoint):
raise HTTPException(status_code=400, detail="Invalid or malicious model path")

try:
input_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{audio.filename}")
with open(input_path, "wb") as buffer:
shutil.copyfileobj(audio.file, buffer)

cmd = [
sys.executable, "asr_inference.py",
"--model_checkpoint", model_checkpoint,
"--audio_path", input_path,
]

process = subprocess.run(cmd, capture_output=True, text=True, check=True)
transcription = process.stdout.strip()

return {"transcription": transcription}

except subprocess.CalledProcessError as e:
return JSONResponse(status_code=500, content={"error": "ASR inference script failed", "details": e.stderr})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})

@app.post("/inference/image-classification", summary="Run Image Classification Inference")
async def run_image_classification_inference(
model_checkpoint: str = Form(..., description="Path to the model checkpoint"),
Expand Down Expand Up @@ -1061,7 +1039,7 @@ async def run_semantic_segmentation_inference(
image: UploadFile = File(..., description="The image to segment.")
):
"""
Runs the 'semantic_segmentation_inference.py' script on an uploaded image
Runs the 'image_segmentation_inference.py' script on an uploaded image
and returns a URL to the annotated output image.
"""
if not job_store.is_valid_checkpoint_path(model_checkpoint):
Expand All @@ -1076,7 +1054,7 @@ async def run_semantic_segmentation_inference(
output_path = os.path.join(OUTPUT_DIR, output_filename)

cmd = [
sys.executable, "semantic_segmentation_inference.py",
sys.executable, "image_segmentation_inference.py",
"--model_checkpoint", model_checkpoint,
"--image_path", input_path,
"--output_path", output_path
Expand Down
63 changes: 41 additions & 22 deletions image_segmentation_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,22 @@ def run_inference(model_checkpoint: str, image_path: str, output_path: str):
model = smp.Unet(encoder_name=encoder_name, encoder_weights=None, in_channels=3, classes=num_classes)
else:
model = smp.UnetPlusPlus(encoder_name=encoder_name, encoder_weights=None, in_channels=3, classes=num_classes)

model.load_state_dict(torch.load(os.path.join(model_checkpoint, "pytorch_model.bin"), map_location=device))

# The Trainer saves weights as model.safetensors by default; older runs
# may have pytorch_model.bin. Load whichever is present.
safetensors_path = os.path.join(model_checkpoint, "model.safetensors")
bin_path = os.path.join(model_checkpoint, "pytorch_model.bin")
if os.path.exists(safetensors_path):
from safetensors.torch import load_file
state_dict = load_file(safetensors_path, device="cpu")
elif os.path.exists(bin_path):
state_dict = torch.load(bin_path, map_location=device)
else:
raise FileNotFoundError(
f"No SMP weights found in {model_checkpoint} "
"(looked for model.safetensors and pytorch_model.bin)."
)
model.load_state_dict(state_dict)

# SMP needs albumentations validation transform
# SMP inference requires a predefined size, assuming 512 if not specified.
Expand Down Expand Up @@ -75,29 +89,34 @@ def run_inference(model_checkpoint: str, image_path: str, output_path: str):
outputs = model(**inputs)
logits = outputs.logits

# 3. Post-process the logits

# 4. Create a color mask
# We need the color map. Let's try to get it from the model config.
color_map = None
if hasattr(model.config, "id2label"):
id2label = model.config.id2label
# Create a simple color map based on label ID
# We'll just generate random colors for this demo
palette = np.random.randint(0, 255, (len(id2label), 3), dtype=np.uint8)
palette[0] = [0, 0, 0] # Make background (label 0) black

color_map = palette[pred_seg]
color_mask = Image.fromarray(color_map.astype(np.uint8), "RGB")
# 3. Post-process the logits into a predicted segmentation map.
# HF models (e.g. SegFormer) emit logits at a reduced resolution, so we
# upsample to the original image size before taking the argmax. This also
# guarantees the mask matches the source image dimensions for overlaying.
orig_w, orig_h = image.size # PIL reports (width, height)
logits = torch.as_tensor(logits)
upsampled_logits = torch.nn.functional.interpolate(
logits, size=(orig_h, orig_w), mode="bilinear", align_corners=False
)
pred_seg = upsampled_logits.argmax(dim=1)[0].to("cpu").numpy().astype(np.uint8)

# 5. Overlay the mask on the original image
if color_map is not None:
overlay_image = Image.blend(image, color_mask, alpha=0.5)
# 4. Build a color mask. A fixed RNG seed keeps colors stable across runs.
if is_smp:
n_labels = num_classes
elif hasattr(model, "config") and getattr(model.config, "id2label", None):
n_labels = len(model.config.id2label)
else:
# Fallback: just save the raw segmentation map (will look black/white)
overlay_image = Image.fromarray(pred_seg.astype(np.uint8))
n_labels = int(pred_seg.max()) + 1
n_labels = max(int(n_labels), int(pred_seg.max()) + 1, 2)

rng = np.random.default_rng(42)
palette = rng.integers(0, 256, size=(n_labels, 3), dtype=np.uint8)
palette[0] = [0, 0, 0] # background (label 0) rendered black

color_mask = Image.fromarray(palette[pred_seg], "RGB")

# 6. Save the final image
# 5. Overlay the mask on the original image and save.
overlay_image = Image.blend(image, color_mask, alpha=0.5)
overlay_image.save(output_path)
print(f"Output image saved to: {output_path}")

Expand Down
Loading
Loading