Spaces:
Running on Zero
Running on Zero
File size: 6,506 Bytes
91c8032 e8e21b2 91c8032 b91c2d8 91c8032 b91c2d8 91c8032 b91c2d8 | 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 | """
Optional Tier 1 compute — monocular depth + object detection on ONE frame.
Deploy this as a free Hugging Face Space (CPU basic, 2 vCPU / 16 GB). It is
never required: the app measures rooms without it. Its job is to turn a single
photograph into *suggestions* the surveyor confirms, so the tapping gets
shorter — not to become a second, competing source of geometry.
Contract with the browser client (js/ai/provider.js):
POST {space}/gradio_api/call/detect {"data": ["data:image/jpeg;base64,..."]}
-> {"objects": [ {type, confidence, bearing_deg, ground_v, box} ... ],
"depth_available": bool,
"note": str}
The Space deliberately returns BEARINGS and image-space ground contact points,
not room coordinates. Only the browser knows the camera pose and height, so only
the browser can turn a detection into a floor position. Keeping that boundary
means a wrong model can misname a sofa but can never move a wall.
"""
import base64
import io
import json
import re
import gradio as gr
import spaces
import numpy as np
from PIL import Image
# --------------------------------------------------------------------------
# Models. Both are small enough for the free CPU tier; first call pays the
# download and warm-up cost, so the client is told to expect a slow first hit.
# --------------------------------------------------------------------------
from transformers import pipeline
DETECTOR_ID = "hustvl/yolos-small"
DEPTH_ID = "depth-anything/Depth-Anything-V2-Small-hf"
_detector = None
_depther = None
def detector():
global _detector
if _detector is None:
_detector = pipeline("object-detection", model=DETECTOR_ID)
return _detector
def depther():
global _depther
if _depther is None:
try:
_depther = pipeline("depth-estimation", model=DEPTH_ID)
except Exception:
_depther = False # depth is optional; detection still works
return _depther
# --------------------------------------------------------------------------
# COCO label -> the app's object catalogue (js/core/schema.js OBJECTS).
# Anything not in this map is dropped: an unknown label is worse than silence,
# because the surveyor has to read and reject it.
# --------------------------------------------------------------------------
LABEL_MAP = {
"bed": "bed_double",
"couch": "sofa_3",
"chair": "chair",
"dining table": "dining_table",
"tv": "tv",
"toilet": "toilet",
"sink": "basin",
"refrigerator": "fridge",
"oven": "oven",
"microwave": "cabinet",
"book": "bookshelf",
"potted plant": "plant",
"vase": "plant",
"clock": None,
"person": None,
}
MIN_SCORE = 0.45
def decode(data_url: str) -> Image.Image:
if not isinstance(data_url, str):
raise ValueError("expected a data URL")
payload = re.sub(r"^data:image/[a-zA-Z]+;base64,", "", data_url)
return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
@spaces.GPU
def detect(image_data_url, fov_deg=66.0):
"""One frame in, structured suggestions out."""
try:
img = decode(image_data_url)
except Exception as exc:
return {"objects": [], "depth_available": False, "note": f"bad image: {exc}"}
w, h = img.size
results = detector()(img)
depth_map = None
dp = depther()
if dp:
try:
depth_map = np.array(dp(img)["depth"], dtype=np.float32)
except Exception:
depth_map = None
objects = []
for r in results:
if r["score"] < MIN_SCORE:
continue
mapped = LABEL_MAP.get(r["label"])
if not mapped:
continue
box = r["box"]
cx = (box["xmin"] + box["xmax"]) / 2.0
# The GROUND CONTACT point: the bottom edge of the box is where the
# object meets the floor, which is the only part of a detection that
# can be turned into a floor position.
ground_v = box["ymax"]
# Horizontal bearing off the optical axis, from the pinhole model.
# The client re-derives this from its own field of view if it disagrees.
half = np.tan(np.radians(fov_deg) / 2.0)
nx = (cx / w) * 2.0 - 1.0
bearing = float(np.degrees(np.arctan(nx * half)))
rel_depth = None
if depth_map is not None:
y0 = int(max(0, min(depth_map.shape[0] - 1, ground_v - 4)))
x0 = int(max(0, min(depth_map.shape[1] - 1, cx)))
patch = depth_map[max(0, y0 - 3):y0 + 4, max(0, x0 - 3):x0 + 4]
if patch.size:
rel_depth = float(np.median(patch))
objects.append({
"type": mapped,
"label": r["label"],
"confidence": round(float(r["score"]), 3),
"bearing_deg": round(bearing, 2),
"ground_v": round(float(ground_v) / h, 4), # normalised 0..1
"relative_depth": rel_depth,
"box": {k: round(float(v), 1) for k, v in box.items()},
})
objects.sort(key=lambda o: -o["confidence"])
return {
"objects": objects[:24],
"depth_available": depth_map is not None,
"image_size": [w, h],
"note": "Bearings and ground-contact points only. The client converts "
"these to room coordinates using its own camera pose.",
}
@spaces.GPU
def depth_png(image_data_url):
"""Debug view: the raw relative depth map, for eyeballing a scan."""
dp = depther()
if not dp:
return None
img = decode(image_data_url)
d = np.array(dp(img)["depth"], dtype=np.float32)
d = (d - d.min()) / (d.ptp() + 1e-6)
return Image.fromarray((d * 255).astype(np.uint8))
with gr.Blocks(title="Room Scanner — assist") as demo:
gr.Markdown(
"## Room Scanner — optional detection assist\n"
"Single frame in, object suggestions out. This service never returns "
"room geometry: the browser owns the camera pose, so the browser owns "
"the measurements."
)
with gr.Row():
inp = gr.Textbox(label="Image data URL", lines=3)
fov = gr.Number(label="Horizontal FOV (deg)", value=66.0)
out = gr.JSON(label="Detections")
gr.Button("Detect").click(detect, inputs=[inp, fov], outputs=out, api_name="detect")
dbg_out = gr.Image(label="Relative depth")
gr.Button("Depth map").click(depth_png, inputs=[inp], outputs=dbg_out, api_name="depth")
if __name__ == "__main__":
demo.queue(max_size=8).launch() |