Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/sync-to-hf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Sync to Hugging Face Space

# Mirrors every push to `main` into the Hugging Face Space, carrying Git LFS
# objects so OrbitMind's YOLO26 weights + demo video deploy as real binaries
# (not LFS pointer stubs). GitHub and the Space are separate git repos — this
# is what makes commits actually appear on the deployed app.
#
# One-time setup: add a repo secret named HF_TOKEN (a Hugging Face *write*
# access token) under Settings → Secrets and variables → Actions.

on:
push:
branches: [main]
workflow_dispatch: {}

jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout (full history + LFS)
uses: actions/checkout@v4
with:
fetch-depth: 0
lfs: true

- name: Pull LFS objects
run: git lfs pull

- name: Push to Hugging Face Space
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
if [ -z "$HF_TOKEN" ]; then
echo "::warning::HF_TOKEN secret is not set — skipping Hugging Face sync. Add an HF write token under repo Settings → Secrets → Actions to enable auto-deploy."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Force-push the current main onto the Space's main branch (LFS-aware).
git push --force "https://Rishet11:${HF_TOKEN}@huggingface.co/spaces/Rishet11/spaceatc" HEAD:main
Comment on lines +39 to +40
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ COPY backend/ ./backend/
COPY OrbitMind/ ./OrbitMind/
COPY --from=frontend /fe/dist ./frontend/dist

# Smoke-check the YOLO26 weights load with the installed ultralytics. Non-fatal:
# logs a clear warning if the weights are an unresolved LFS pointer or the wheel
# is too old, instead of silently breaking OrbitMind inference at runtime.
RUN python OrbitMind/check_model.py || true

# Grant write permissions to the OrbitMind folder so the non-root HF user can save video uploads
RUN mkdir -p ./OrbitMind/uploads && chmod -R 777 ./OrbitMind

Expand Down
47 changes: 47 additions & 0 deletions OrbitMind/check_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Build-time smoke check for the OrbitMind YOLO26 detector.

Run during the Docker build (see Dockerfile). It is intentionally NON-FATAL:
it prints a clear warning instead of failing the build, so that an unresolved
Git LFS pointer or a too-old ultralytics wheel is visible in the build log
rather than silently breaking OrbitMind inference at runtime. The app itself
degrades gracefully (the reflex endpoints return a clear 503 when assets are
missing), so the rest of the Space — globe, negotiation, metrics — still works.
"""

import os
import sys

WEIGHTS = os.path.join(os.path.dirname(__file__), "best (1).pt")
# Real YOLO26 weights are ~20 MB; a Git LFS pointer file is ~130 bytes.
LFS_STUB_MAX_BYTES = 1024


def main() -> int:
if not os.path.exists(WEIGHTS):
print(f"[build][WARN] YOLO weights missing: {WEIGHTS} — OrbitMind inference will be disabled.")
return 0

size = os.path.getsize(WEIGHTS)
if size < LFS_STUB_MAX_BYTES:
print(
f"[build][WARN] {WEIGHTS} is {size} B — looks like an unresolved Git LFS pointer. "
"Deploy the real binary (git lfs) for OrbitMind inference to work."
)
return 0

try:
from ultralytics import YOLO

YOLO(WEIGHTS)
print(f"[build][OK] YOLO26 weights loaded ({size / 1e6:.1f} MB) with installed ultralytics.")
except Exception as e: # too-old ultralytics for YOLO26, corrupt file, etc.
print(
f"[build][WARN] Could not load YOLO26 weights ({e!r}). "
"Confirm the installed ultralytics version supports YOLO26. "
"OrbitMind will degrade gracefully at runtime."
)
return 0


if __name__ == "__main__":
sys.exit(main())
4 changes: 2 additions & 2 deletions OrbitMind/inference_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ def main():
parser = argparse.ArgumentParser(description="OrbitMind Video Inference Script")
parser.add_argument("--video", type=str, default="output_h264.mp4", help="Path to input video")
parser.add_argument("--output", type=str, default="output_inference.mp4", help="Path to save output video")
parser.add_argument("--yolo", type=str, default="best (1).pt", help="Path to YOLOv8 weights")
parser.add_argument("--yolo", type=str, default="best (1).pt", help="Path to YOLO26 weights")
parser.add_argument("--kpt", type=str, default="keypoint_mobilenet.pth", help="Path to KeypointMobileNet weights")
parser.add_argument("--camera_json", type=str, default="camera.json", help="Path to camera matrix JSON")
parser.add_argument("--tango_mat", type=str, default="tangoPoints.mat", help="Path to tangoPoints MAT file")
Expand All @@ -234,7 +234,7 @@ def main():
print(f"Using device: {CFG['device']}")

# Load Models
print("Loading YOLOv8 model...")
print("Loading YOLO26 model...")
yolo_model = YOLO(args.yolo)

print("Loading KeypointMobileNet...")
Expand Down
15 changes: 11 additions & 4 deletions backend/agents/nodes/tle_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ async def ingest_tle(state: AgentState) -> dict:
"""
logger.info("Node: ingest_tle starting...")

# 1. Fetch & parse
satellites_raw = await fetch_and_parse(CELESTRAK_STARLINK_TLE)

# 1. Fetch & parse (track where the data actually came from)
satellites_raw, source = await fetch_and_parse(
CELESTRAK_STARLINK_TLE, return_source=True
)

# 2. Take first 100
top_100 = satellites_raw[:100]

Expand Down Expand Up @@ -86,7 +88,12 @@ async def ingest_tle(state: AgentState) -> dict:
"maneuver_count": meta["maneuver_count"],
})

msg = f"[TLE INGESTION] Loaded {len(processed_sats)} active satellites from CelesTrak"
source_label = {
"network": "CelesTrak (live)",
"local_cache": "local cache (CelesTrak unreachable)",
"unavailable": "no source available",
}.get(source, source)
msg = f"[TLE INGESTION] Loaded {len(processed_sats)} active satellites from {source_label}"
msg2 = "[TLE INGESTION] Coverage: SpaceX Starlink, OneWeb, active payloads"
new_messages = [msg, msg2]

Expand Down
73 changes: 70 additions & 3 deletions backend/api/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def lazy_load_models():
device = CFG["device"]

if YOLO_MODEL is None:
logger.info("Lazy loading YOLOv8 model for Reflex API...")
logger.info("Lazy loading YOLO26 model for Reflex API...")
YOLO_MODEL = YOLO(YOLO_WEIGHTS)

if KPT_MODEL is None:
Expand Down Expand Up @@ -174,9 +174,62 @@ def estimate_pose(kpts_2d, kp3d, camera_matrix, dist_coeffs):
quat = rotation_matrix_to_quaternion(R)
return quat, tvec

# ---------------------------------------------------------------------------
# Asset guards + frame cache
# ---------------------------------------------------------------------------
# Real weights/video are MB-scale; an unresolved Git LFS pointer is ~130 bytes.
LFS_STUB_MAX_BYTES = 1024


def _missing_or_stub(path: str) -> bool:
"""True if a required binary is absent or an unresolved Git LFS pointer."""
return (not os.path.exists(path)) or os.path.getsize(path) < LFS_STUB_MAX_BYTES


def _require_inference_assets():
"""Raise a clear 503 if the model weights aren't really deployed (LFS stubs)."""
missing = []
if _missing_or_stub(YOLO_WEIGHTS):
missing.append("YOLO26 weights (best (1).pt)")
if _missing_or_stub(KPT_WEIGHTS):
missing.append("keypoint weights (keypoint_mobilenet.pth)")
if missing:
raise HTTPException(
status_code=503,
detail=(
"OrbitMind inference assets are unavailable on this deployment: "
+ ", ".join(missing)
+ ". These are Git LFS files — deploy them as real binaries, not LFS pointers."
),
)


def _require_video(path: str):
"""Raise a clear 503 if the active video is missing or an LFS stub."""
if _missing_or_stub(path):
raise HTTPException(
status_code=503,
detail=(
"The video feed is unavailable (missing or an unresolved Git LFS pointer). "
"Upload a video, or ensure the default clip is deployed as a real binary."
),
)


# Cache of fully-computed frame responses keyed by (video_path, frame_idx) so
# scrubbing/replaying a frame is instant instead of re-running YOLO26 + pose on
# CPU. Cleared whenever the active video changes (upload / reset).
_FRAME_CACHE: dict[tuple[str, int], dict] = {}


def _clear_frame_cache():
_FRAME_CACHE.clear()


@router.get("/api/reflex/total_frames")
async def get_total_frames():
"""Returns the total number of frames in the speed dataset video."""
_require_video(CURRENT_VIDEO_PATH)
cap = cv2.VideoCapture(CURRENT_VIDEO_PATH, cv2.CAP_FFMPEG)
if not cap.isOpened():
cap = cv2.VideoCapture(CURRENT_VIDEO_PATH)
Expand All @@ -192,9 +245,19 @@ async def get_frame(frame_idx: int):
"""
Seeks to frame_idx, runs YOLO + Keypoint detection + Pose estimation,
overlays visualization on the frame, base64 encodes it, and returns the response.

Results are memoized per (video, frame) so replaying or scrubbing a frame is
instant instead of re-running the CPU pipeline.
"""
cache_key = (CURRENT_VIDEO_PATH, frame_idx)
cached = _FRAME_CACHE.get(cache_key)
if cached is not None:
return cached

_require_video(CURRENT_VIDEO_PATH)
_require_inference_assets()
lazy_load_models()

cap = cv2.VideoCapture(CURRENT_VIDEO_PATH, cv2.CAP_FFMPEG)
if not cap.isOpened():
cap = cv2.VideoCapture(CURRENT_VIDEO_PATH)
Expand Down Expand Up @@ -287,7 +350,7 @@ async def get_frame(frame_idx: int):
status, threat_level, detected, distance, tvec_coords, quat
)

return {
response = {
"image": img_b64,
"box": box_coords,
"keypoints": kpts_list,
Expand All @@ -301,6 +364,8 @@ async def get_frame(frame_idx: int):
"decision_log": decision_log,
"dodge_command": dodge_command
}
_FRAME_CACHE[cache_key] = response
return response
Comment on lines +367 to +368

@router.post("/api/reflex/upload")
async def upload_video(file: UploadFile = File(...)):
Expand Down Expand Up @@ -380,6 +445,7 @@ def _cleanup():

CURRENT_VIDEO_PATH = target_path
reset_decision_cache() # fresh reflex reasoning for the new feed
_clear_frame_cache() # invalidate memoized frames from the previous video
logger.info(
"Reflex video switched to: %s (backend=%s, reported=%d, total_frames=%d)",
target_path, open_backend, reported, total_frames,
Expand All @@ -396,6 +462,7 @@ async def reset_video():
global CURRENT_VIDEO_PATH
CURRENT_VIDEO_PATH = VIDEO_PATH
reset_decision_cache() # fresh reflex reasoning for the default feed
_clear_frame_cache() # invalidate memoized frames from the uploaded video

cap = cv2.VideoCapture(CURRENT_VIDEO_PATH)
total_frames = 0
Expand Down
25 changes: 21 additions & 4 deletions backend/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import uuid
import json
import logging
import time as time_module
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
Expand All @@ -23,6 +24,7 @@
from sgp4.api import Satrec

router = APIRouter()
logger = logging.getLogger(__name__)

# In-memory cache of Satrec objects populated by main.py background task
# so GET /api/satellites can return propagated positions.
Expand Down Expand Up @@ -339,18 +341,33 @@ async def demo_inject():
sat_cache["99001"] = {"satrec": satrec_a, "name": name_a, "operator": demo_a["operator"]}
sat_cache["99002"] = {"satrec": satrec_b, "name": name_b, "operator": demo_b["operator"]}

# 3. Run full pipeline
# 3. Run full pipeline (wrapped: a pipeline exception must surface as a clean
# error response, not an unhandled 500 the frontend silently swallows).
global latest_session_id
latest_session_id = str(uuid.uuid4())
state = await run_pipeline(latest_session_id)

try:
state = await run_pipeline(latest_session_id)
except Exception as e:
logger.error("demo_inject pipeline failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Conjunction pipeline failed: {e}")
Comment on lines +349 to +352

# Find the injected event (the one with DEMO-SAT-A)
event_id = None
for ev in state.get("active_conjunctions", []):
if "DEMO-SAT" in ev["sat_primary"] or "DEMO-SAT" in ev["sat_secondary"]:
event_id = ev["event_id"]
break


if event_id is None:
# Pipeline completed but produced no conjunction (e.g. screening found no
# close approach). Report it honestly instead of a misleading "injected".
logger.warning("demo_inject completed but no conjunction was detected")
return {
"status": "no_conjunction",
"event_id": None,
"detail": "Pipeline ran but no conjunction was detected.",
}

return {"status": "injected", "event_id": event_id, "expected_tca_seconds": 120}

@router.post("/api/hitl/{event_id}/approve")
Expand Down
3 changes: 1 addition & 2 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,7 @@ async def lifespan(app: FastAPI):
# 2. fetch TLEs and store top 100 satellites
logger.info("Fetching TLEs...")
try:
sats = await fetch_and_parse(CELESTRAK_STARLINK_TLE)
source = "network"
sats, source = await fetch_and_parse(CELESTRAK_STARLINK_TLE, return_source=True)

# Guarantee a non-empty globe: if the network fetch yielded nothing
# (blocked host, empty 200 response, parse failure), fall back to the
Expand Down
Loading