OMD-DM commited on
Commit
91c8032
·
verified ·
1 Parent(s): cde5dc0

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +189 -0
  2. requirements.txt +7 -0
app.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optional Tier 1 compute — monocular depth + object detection on ONE frame.
3
+
4
+ Deploy this as a free Hugging Face Space (CPU basic, 2 vCPU / 16 GB). It is
5
+ never required: the app measures rooms without it. Its job is to turn a single
6
+ photograph into *suggestions* the surveyor confirms, so the tapping gets
7
+ shorter — not to become a second, competing source of geometry.
8
+
9
+ Contract with the browser client (js/ai/provider.js):
10
+
11
+ POST {space}/gradio_api/call/detect {"data": ["data:image/jpeg;base64,..."]}
12
+ -> {"objects": [ {type, confidence, bearing_deg, ground_v, box} ... ],
13
+ "depth_available": bool,
14
+ "note": str}
15
+
16
+ The Space deliberately returns BEARINGS and image-space ground contact points,
17
+ not room coordinates. Only the browser knows the camera pose and height, so only
18
+ the browser can turn a detection into a floor position. Keeping that boundary
19
+ means a wrong model can misname a sofa but can never move a wall.
20
+ """
21
+
22
+ import base64
23
+ import io
24
+ import json
25
+ import re
26
+
27
+ import gradio as gr
28
+ import numpy as np
29
+ from PIL import Image
30
+
31
+ # --------------------------------------------------------------------------
32
+ # Models. Both are small enough for the free CPU tier; first call pays the
33
+ # download and warm-up cost, so the client is told to expect a slow first hit.
34
+ # --------------------------------------------------------------------------
35
+ from transformers import pipeline
36
+
37
+ DETECTOR_ID = "hustvl/yolos-small"
38
+ DEPTH_ID = "depth-anything/Depth-Anything-V2-Small-hf"
39
+
40
+ _detector = None
41
+ _depther = None
42
+
43
+
44
+ def detector():
45
+ global _detector
46
+ if _detector is None:
47
+ _detector = pipeline("object-detection", model=DETECTOR_ID)
48
+ return _detector
49
+
50
+
51
+ def depther():
52
+ global _depther
53
+ if _depther is None:
54
+ try:
55
+ _depther = pipeline("depth-estimation", model=DEPTH_ID)
56
+ except Exception:
57
+ _depther = False # depth is optional; detection still works
58
+ return _depther
59
+
60
+
61
+ # --------------------------------------------------------------------------
62
+ # COCO label -> the app's object catalogue (js/core/schema.js OBJECTS).
63
+ # Anything not in this map is dropped: an unknown label is worse than silence,
64
+ # because the surveyor has to read and reject it.
65
+ # --------------------------------------------------------------------------
66
+ LABEL_MAP = {
67
+ "bed": "bed_double",
68
+ "couch": "sofa_3",
69
+ "chair": "chair",
70
+ "dining table": "dining_table",
71
+ "tv": "tv",
72
+ "toilet": "toilet",
73
+ "sink": "basin",
74
+ "refrigerator": "fridge",
75
+ "oven": "oven",
76
+ "microwave": "cabinet",
77
+ "book": "bookshelf",
78
+ "potted plant": "plant",
79
+ "vase": "plant",
80
+ "clock": None,
81
+ "person": None,
82
+ }
83
+
84
+ MIN_SCORE = 0.45
85
+
86
+
87
+ def decode(data_url: str) -> Image.Image:
88
+ if not isinstance(data_url, str):
89
+ raise ValueError("expected a data URL")
90
+ payload = re.sub(r"^data:image/[a-zA-Z]+;base64,", "", data_url)
91
+ return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
92
+
93
+
94
+ def detect(image_data_url, fov_deg=66.0):
95
+ """One frame in, structured suggestions out."""
96
+ try:
97
+ img = decode(image_data_url)
98
+ except Exception as exc:
99
+ return {"objects": [], "depth_available": False, "note": f"bad image: {exc}"}
100
+
101
+ w, h = img.size
102
+ results = detector()(img)
103
+
104
+ depth_map = None
105
+ dp = depther()
106
+ if dp:
107
+ try:
108
+ depth_map = np.array(dp(img)["depth"], dtype=np.float32)
109
+ except Exception:
110
+ depth_map = None
111
+
112
+ objects = []
113
+ for r in results:
114
+ if r["score"] < MIN_SCORE:
115
+ continue
116
+ mapped = LABEL_MAP.get(r["label"])
117
+ if not mapped:
118
+ continue
119
+
120
+ box = r["box"]
121
+ cx = (box["xmin"] + box["xmax"]) / 2.0
122
+ # The GROUND CONTACT point: the bottom edge of the box is where the
123
+ # object meets the floor, which is the only part of a detection that
124
+ # can be turned into a floor position.
125
+ ground_v = box["ymax"]
126
+
127
+ # Horizontal bearing off the optical axis, from the pinhole model.
128
+ # The client re-derives this from its own field of view if it disagrees.
129
+ half = np.tan(np.radians(fov_deg) / 2.0)
130
+ nx = (cx / w) * 2.0 - 1.0
131
+ bearing = float(np.degrees(np.arctan(nx * half)))
132
+
133
+ rel_depth = None
134
+ if depth_map is not None:
135
+ y0 = int(max(0, min(depth_map.shape[0] - 1, ground_v - 4)))
136
+ x0 = int(max(0, min(depth_map.shape[1] - 1, cx)))
137
+ patch = depth_map[max(0, y0 - 3):y0 + 4, max(0, x0 - 3):x0 + 4]
138
+ if patch.size:
139
+ rel_depth = float(np.median(patch))
140
+
141
+ objects.append({
142
+ "type": mapped,
143
+ "label": r["label"],
144
+ "confidence": round(float(r["score"]), 3),
145
+ "bearing_deg": round(bearing, 2),
146
+ "ground_v": round(float(ground_v) / h, 4), # normalised 0..1
147
+ "relative_depth": rel_depth,
148
+ "box": {k: round(float(v), 1) for k, v in box.items()},
149
+ })
150
+
151
+ objects.sort(key=lambda o: -o["confidence"])
152
+ return {
153
+ "objects": objects[:24],
154
+ "depth_available": depth_map is not None,
155
+ "image_size": [w, h],
156
+ "note": "Bearings and ground-contact points only. The client converts "
157
+ "these to room coordinates using its own camera pose.",
158
+ }
159
+
160
+
161
+ def depth_png(image_data_url):
162
+ """Debug view: the raw relative depth map, for eyeballing a scan."""
163
+ dp = depther()
164
+ if not dp:
165
+ return None
166
+ img = decode(image_data_url)
167
+ d = np.array(dp(img)["depth"], dtype=np.float32)
168
+ d = (d - d.min()) / (d.ptp() + 1e-6)
169
+ return Image.fromarray((d * 255).astype(np.uint8))
170
+
171
+
172
+ with gr.Blocks(title="Room Scanner — assist") as demo:
173
+ gr.Markdown(
174
+ "## Room Scanner — optional detection assist\n"
175
+ "Single frame in, object suggestions out. This service never returns "
176
+ "room geometry: the browser owns the camera pose, so the browser owns "
177
+ "the measurements."
178
+ )
179
+ with gr.Row():
180
+ inp = gr.Textbox(label="Image data URL", lines=3)
181
+ fov = gr.Number(label="Horizontal FOV (deg)", value=66.0)
182
+ out = gr.JSON(label="Detections")
183
+ gr.Button("Detect").click(detect, inputs=[inp, fov], outputs=out, api_name="detect")
184
+
185
+ dbg_out = gr.Image(label="Relative depth")
186
+ gr.Button("Depth map").click(depth_png, inputs=[inp], outputs=dbg_out, api_name="depth")
187
+
188
+ if __name__ == "__main__":
189
+ demo.queue(max_size=8).launch()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio==5.9.1
2
+ transformers==4.46.3
3
+ torch==2.5.1
4
+ torchvision==0.20.1
5
+ timm==1.0.11
6
+ pillow==11.0.0
7
+ numpy==2.1.3