# Plain pip wheels throughout — the former PDAL dependency (conda-forge only, # broke Spaces builds) was replaced by laspy + pyproj, verified bit-identical. # Base image pinned by digest (python:3.11-slim) so a rebuild is reproducible. FROM python:3.11-slim@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3 WORKDIR /app # CPU-only torch: ~1.5 GB smaller than the default CUDA build. Pinned to match CI; # torchvision is required by transformers' Mask2Former image processor. RUN pip install --no-cache-dir torch==2.6.0 torchvision==0.21.0 --index-url https://download.pytorch.org/whl/cpu # Postgres driver for the durable prod store (P1d). Installed separately from the # hash-pinned lock (same pattern as torch) — a prebuilt binary wheel, no libpq build. # Used only when DATABASE_URL is a postgres:// URL (Neon); SQLite is the default otherwise. RUN pip install --no-cache-dir "psycopg[binary]>=3.2,<3.3" # Clerk session-JWT verification (P2a dashboard auth): PyJWT + cryptography for RS256/JWKS. # Installed separately from the hash-pinned lock (prebuilt wheels). Used only when # CLERK_SECRET_KEY is set; otherwise the dashboards run on the dev-token shim. RUN pip install --no-cache-dir "pyjwt[crypto]>=2.8,<3" # Cloud Tasks client for serverless batch dispatch (B3). Installed separately from the # hash-pinned lock. Used only when the CLOUD_TASKS_* env is set (Cloud Run); otherwise batch # runs on the in-process worker and this stays dormant. RUN pip install --no-cache-dir "google-cloud-tasks>=2.16,<3" # S3-compatible client for the optional Cloudflare R2 LiDAR tile cache (A3). Installed # separately from the hash-pinned lock (prebuilt wheels). Used only when the R2_* env is # set; otherwise the tile cache is a no-op and tiles come straight from Douglas S3 / USGS. RUN pip install --no-cache-dir "boto3>=1.34,<2" # Error tracking (observability MVP). Installed separately from the hash-pinned lock. Used # only when SENTRY_DSN is set; otherwise Sentry init is a no-op and this stays dormant. RUN pip install --no-cache-dir "sentry-sdk>=2.0,<3" # Runtime deps from the hash-pinned lock (uv pip compile of requirements-api.txt). # --require-hashes refuses anything whose hash isn't listed, so the whole tree is # reproducible and tamper-evident. Regenerate the lock when requirements-api.txt changes. COPY requirements-api.lock . RUN pip install --no-cache-dir --require-hashes -r requirements-api.lock COPY pyproject.toml ./ COPY src/ src/ RUN pip install --no-cache-dir --no-deps . # Hugging Face Spaces runs the container as UID 1000 with ephemeral disk. RUN useradd -m -u 1000 user && mkdir -p /app/data && chown -R user:user /app USER user ENV HOME=/home/user ENV HF_HOME=/home/user/.cache/huggingface ENV MPLBACKEND=Agg ENV MPLCONFIGDIR=/tmp/mpl # Pre-bake the segmentation models into the image so ephemeral hosts don't # re-download them from Hugging Face after every restart. mask2former (~200 MB) # is always used; SAM (~375 MB) backs the opt-in SAM_RESTRICT path — baking it # avoids a ~1-2 min download on the first quote after each startup when enabled. # IDs + revisions are imported from the installed package (single source of truth), # so the baked weights always match what the code loads at runtime. RUN python -c "from transformers import AutoImageProcessor, AutoModelForUniversalSegmentation as M; \ from lawn_estimator.segmentation import LAWN_MODEL_ID as i, MODEL_REVISIONS as r; \ AutoImageProcessor.from_pretrained(i, revision=r[i]); \ M.from_pretrained(i, revision=r[i])" RUN python -c "from transformers import SamModel, SamProcessor; \ from lawn_estimator.restrict import SAM_MODEL as s, SAM_REVISION as v; \ SamModel.from_pretrained(s, revision=v); \ SamProcessor.from_pretrained(s, revision=v)" # EoMT-DINOv3 (~1.2 GB) backs the opt-in LAWN_CASCADE path — without the bake, # the first cascade quote after every restart pulls it from HF (minutes of stall). RUN python -c "from transformers import AutoImageProcessor, AutoModelForUniversalSegmentation as M; \ from lawn_estimator.segmentation import CASCADE_PRIMARY_MODEL_ID as i, MODEL_REVISIONS as r; \ AutoImageProcessor.from_pretrained(i, revision=r[i]); \ M.from_pretrained(i, revision=r[i])" # Statewide parcels GeoPackage (~1.1 GB, private HF dataset) backs the opt-in # PARCELS_FALLBACK path. HF_TOKEN arrives as a BuildKit secret (Space secret of # the same name); when absent (e.g. a local build) the bake is skipped and the # fallback simply reports the file as missing if ever enabled. RUN --mount=type=secret,id=HF_TOKEN,mode=0444,required=false \ python -c "import os; \ tok = open('/run/secrets/HF_TOKEN').read().strip() if os.path.exists('/run/secrets/HF_TOKEN') else None; \ from huggingface_hub import hf_hub_download; \ hf_hub_download('TempuraML/ne-parcels', 'ne_parcels.gpkg', repo_type='dataset', token=tok, local_dir='data/ne_parcels') if tok else print('HF_TOKEN absent - skipping parcels bake')" RUN mkdir -p data/lidar/tile_index data/outputs EXPOSE 8000 CMD ["uvicorn", "lawn_estimator.api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]