Spaces:
Running on Zero
Running on Zero
File size: 11,431 Bytes
4b98524 02a6c94 4b98524 f84c408 4b98524 f84c408 4b98524 f84c408 4b98524 f84c408 9d36f13 5eaf393 9d36f13 5eaf393 f84c408 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """The Space entry point: the Animap service, plus a page a person can use.
Hugging Face runs this file, published to the Space root as `space_app.py`.
**Not `app.py`.** The service's own package is `app/` and sits at that same
root; a module of the same name beside it is a collision Python resolves in
favour of the package, so an entry point called `app.py` cannot be imported by
name. The first staging was called that, and `import app` returned the package. It exists because **ZeroGPU is Gradio-SDK only** β
the Docker Space this replaced ran the identical Azure image and could not be
given a GPU at any price, which is the whole reason CountGD has never been
measured end to end.
## What is served, and at which path
/ the Gradio page β upload a photograph, pick a capability
/health the service's own, unauthenticated
/capabilities the published contract
/jobs bearer token, exactly as on Azure
`app.main:app` is mounted whole rather than reimplemented, so the endpoints a
caller already scripts against keep working and the quality gate, the counting
guard and the observation vocabulary are the same code Azure runs. Gradio is
mounted **into** FastAPI rather than the other way round, because the API is the
product and the page is a demonstration of it.
## `@spaces.GPU`, and the honest state of it
ZeroGPU allocates a GPU for the duration of a decorated call and releases it
after. `_run_capability` is decorated, so any capability that reaches for CUDA
gets one.
**Nothing reaches for CUDA today, and that is worth saying plainly rather than
implying a speed-up nobody will see.** The two runnable artefacts here are
YOLOX-m and DINOv3, both ONNX, both executed by `onnxruntime` on CPU. What this
file buys is the *ability* to be given a GPU, which is the prerequisite for the
one capability that needs one: CountGD gets MAE **14.84** on broiler houses
against the deployed detector's **156.80**, and it is a PyTorch model that has
never been runnable anywhere in this project. The decorator is here so that
landing CountGD is a model change and not another SDK migration.
The decorator is documented as effect-free off ZeroGPU, so the same file runs
locally and on a CPU Space.
## What this Space still is not
**Not the production media path.** Azure reads captures from `animapmedia`
through the Container App's managed identity. A Space has no managed identity,
so this serves `ANIMAP_MEDIA_PROVIDER=local` against two public-domain frames
and no farm data reaches it. That was true of the Docker Space and it is true
here; changing SDK changes nothing about it.
**Not a licence-gate weakening.** The Docker build failed if an AGPL runtime
arrived. A Gradio Space has no Dockerfile to fail, so the gate that matters is
the runtime one that was always there: `providers.discover()` refuses to serve a
capability whose artefact fingerprints as a copyleft runtime, and `/health`
publishes `artefact_licenses` so a deployment in breach is visible from outside.
`scripts/install_models.py --check` is run below at start-up for the same
reason β verify, never fetch.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
# **The demo captures, and where they now live.**
#
# The Docker Space copied them to `/media` in a `Dockerfile.append` stanza. A
# Gradio Space has no Dockerfile, so `publish.py` stages them into
# `space/fixtures/` in the repo and this points the media store at that. Without
# it every job fails with "No media file for β¦ under /home/user/app/media",
# which is the service correctly refusing to invent a photograph.
os.environ.setdefault("ANIMAP_MEDIA_PROVIDER", "local")
os.environ["ANIMAP_MEDIA_ROOT"] = str(HERE / "space" / "fixtures")
# **Before the service is imported.** `providers.discover()` reads the artefacts
# at import time, and a checksum that disagrees with its card should stop the
# Space rather than be discovered by a farm's job. On Docker this was a build
# step; a Gradio Space has no build step, so it is the first thing that runs.
_check = subprocess.run(
[sys.executable, "scripts/install_models.py", "--check"],
cwd=HERE, capture_output=True, text=True,
)
print(_check.stdout or "", flush=True)
if _check.returncode != 0:
print(_check.stderr, file=sys.stderr, flush=True)
raise SystemExit(
"An artefact does not match its model card. Nothing is served: a "
"capability answering from an artefact nobody verified is the one "
"thing this service must not do."
)
import gradio as gr # noqa: E402
import uvicorn # noqa: E402
from app.capabilities import REGISTRY # noqa: E402
from app.main import RUNNERS, app as service, media, provider # noqa: E402
try:
import spaces
except ImportError: # pragma: no cover - only present on a ZeroGPU Space
class _Spaces:
"""A no-op stand-in, so this file runs unchanged off ZeroGPU.
Handles both spellings β bare `@spaces.GPU` and called
`@spaces.GPU(duration=60)` β because the real decorator does and a
stand-in that only handled one would break the local run it exists for.
"""
@staticmethod
def GPU(*args, **kwargs):
if args and callable(args[0]):
return args[0]
def decorate(fn):
return fn
return decorate
spaces = _Spaces()
#: How long one call may hold a GPU.
#:
#: Sixty is ZeroGPU's default and comfortably over the measured worst case: a
#: dense frame runs three detection grids in a few seconds. It is deliberately
#: not raised "to be safe" β a shorter declared duration improves queue priority
#: for everybody, and a capability that genuinely needs longer should say so
#: when it lands.
GPU_SECONDS = 60
@spaces.GPU(duration=GPU_SECONDS)
def _run_capability(capability_key: str, image):
"""One capability against one uploaded image, inside a GPU allocation.
The decorated boundary is here rather than deeper because ZeroGPU allocates
per call: wrapping the whole job means one allocation for a whole answer,
where wrapping an inner tensor op would mean many.
"""
import datetime
import uuid
from app.schemas import InferenceRequest
capability = REGISTRY[capability_key]
runner = RUNNERS[capability_key]
class _Store:
def open_image(self, ref):
return image
request = InferenceRequest(
capability_key=capability_key,
farm_id=uuid.UUID(int=2),
subject_type="animal" if capability.species == "cattle" else "flock_cycle",
subject_id=uuid.UUID(int=1),
media_ids=[uuid.uuid4()],
captured_at=datetime.datetime.now(datetime.timezone.utc),
)
return runner.run(
request=request, capability=capability,
artefact=provider.artefact_for(capability), store=_Store(),
request_id=uuid.uuid4(),
)
def _describe(capability_key: str, image):
"""The page's handler. Returns what the service returned, and its caveats."""
if image is None:
return "Upload a photograph first.", {}
capability = REGISTRY.get(capability_key)
if capability is None or not provider.can_run(capability):
return (
f"**{capability_key}** cannot run here. A capability with no "
f"verified artefact and no configured model answers `unavailable` "
f"rather than a placeholder.",
{},
)
try:
result = _run_capability(capability_key, image)
except Exception as exc: # a refusal is an answer; a crash is not
return f"The run failed: `{type(exc).__name__}: {exc}`", {}
if result.recommended_recapture and not result.observations:
headline = "**Nothing is claimed for this photograph.** Take it again."
else:
rows = [
f"- `{o.type}` = **{o.value}**" + (f" ({o.unit})" if o.unit else "")
for o in result.observations
]
headline = "\n".join(rows) or "_No observation was produced._"
caveats = "\n".join(f"> {w}" for w in result.warnings)
return f"{headline}\n\n{caveats}", result.model_dump(mode="json")
_RUNNABLE = sorted(k for k in RUNNERS if provider.can_run(REGISTRY[k]))
with gr.Blocks(title="Animap inference") as page:
gr.Markdown(
"# Animap inference\n"
"Livestock models that refuse to invent a result. Its most important "
"property is what it **refuses**: a capability with no verified "
"artefact behind it says so rather than returning a plausible number.\n\n"
"**Read the caveats under the answer before you read the answer.** "
"Every capability here is `experimental`, and the hosted ones have "
"never been measured on a Nigerian herd.\n\n"
f"Media provider: `{media.provider}` β two public-domain frames. "
"No farm data reaches this Space."
)
with gr.Row():
with gr.Column():
choice = gr.Dropdown(
choices=_RUNNABLE or ["nothing is runnable here"],
value=(_RUNNABLE[0] if _RUNNABLE else None),
label="Capability",
)
photo = gr.Image(type="pil", label="Photograph")
go = gr.Button("Read it", variant="primary")
with gr.Column():
answer = gr.Markdown(label="What it said")
raw = gr.JSON(label="The result, whole")
go.click(_describe, inputs=[choice, photo], outputs=[answer, raw])
# **Launched by Gradio, with the service mounted onto Gradio's own app.**
#
# The order matters and it is not the obvious one. Mounting Gradio *into* a
# FastAPI app and serving that with `uvicorn.run` works on `cpu-basic` and dies
# on ZeroGPU: the app starts, logs "Uvicorn running on http://0.0.0.0:7860",
# and is immediately shut down. ZeroGPU supervises the Gradio server it expects
# `launch()` to create, and a uvicorn started by hand is not that server.
#
# So Gradio launches, and the whole Animap service is mounted onto the app it
# creates. `prevent_thread_lock` returns control so the mount can happen, and
# `block_thread` then holds the process open β which a Space requires, because
# a script that exits is a runtime error with nothing in the log.
page.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("GRADIO_SERVER_PORT", 7860)),
prevent_thread_lock=True,
show_api=False,
)
# `/animap/health`, `/animap/capabilities`, `/animap/jobs`.
#
# Prefixed because Gradio owns `/` once it has launched.
#
# **And moved to the front of the router, which is the part that actually
# mattered.** Starlette matches routes in order, and `launch()` has already
# registered Gradio's catch-all `GET /{path:path}` for its own client-side
# routing. A mount appended after that is unreachable for GET while still
# working for POST β which is exactly what the first two attempts did:
# `POST /animap/jobs` answered 401, and `GET /animap/health` returned the
# page's HTML. Changing the prefix from `/api` to `/animap` did nothing,
# because the prefix was never the problem.
#
# A half-working mount is worse than a broken one: the half that works reads as
# proof that the wiring is right.
page.app.mount("/animap", service)
page.app.router.routes.insert(0, page.app.router.routes.pop())
page.block_thread()
|