Instructions to use XXXpilar/Fastfill-test with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use XXXpilar/Fastfill-test with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
File size: 21,001 Bytes
e6f80f3 | 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 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 | """Canonical room IR and the text protocol FastFill v1 is trained on.
IR record (one JSON line per room), all lengths in meters, right-handed Z-up, room-local frame
with the boundary AABB min corner at (0, 0):
uid, source, subset, group provenance; `group` = leakage identity (house / scan)
room_type str | None
boundary, boundary_type [[x, y], ...] CCW | None ; "polygon" | "hull" | "proxy" | None
height float | None
objects: [{id, category, size: [sx, sy, sz], pos: [x, y, z], yaw, anchor, parent, desc, tilted}]
size extents along the object's local X/Y/Z (before yaw)
pos bottom center of the object box
yaw radians about +Z, from room +X to the object's local +X = its semantic front
anchor "floor" | "wall" | "ceiling" | "object" | None ; parent = id of supporting object | None
fixed [{id, category, size, pos, yaw}] (build output only): immovable boxes already in the room that the
model is told about but never places (doors, windows, columns, stairs, unlabelled floor boxes)
meta front_known (True only when the adapter verified the front convention), n_incomplete, eval_only, ...
Model I/O (spec: fastfill I/O design review, 2026-09-24; fixed geometry added 2026-09-24), one turn, compact JSON:
system SYSTEM_PROMPT (constant for training, evaluation and inference)
user {"room_type"?, "boundary_type", "boundary", "height"?, "fixed"?: [{id, size, pos, yaw}],
"objects": [{id, size, desc?}], "constraints"?}
assistant {"placements": [{"id", "on"?, "pos": [x, y, z], "yaw": int deg}, ...]}
Fixed items and objects are listed by footprint area (largest first) with ids slug(category)_k over one shared
namespace; the answer lists the objects only, in their listed order.
Compared with OptiScene: polygon instead of area, one id per instance instead of "N description" groups,
local-axis size with a front convention instead of h/w/d, integer degrees instead of 2-decimal radians,
no design-rule meta prompt, no reasoning block (no reasoning data exists), explicit support ("on").
"""
import json
import math
import re
from shapely.geometry import Polygon
from shapely.geometry.polygon import orient
SYSTEM_PROMPT = (
"You are a room layout model. Place every listed object in the room; do not add, drop or resize objects.\n"
"Frame: meters, right-handed, Z up, floor z=0. boundary: floor polygon [[x,y],...], counter-clockwise; "
"boundary_type \"polygon\" = walls, \"hull\" = convex hull of a scanned floor (the real floor may be smaller). "
"height: ceiling height. A missing key means unknown.\n"
"fixed: boxes already in the room that cannot move (doors, windows, columns, stairs, ...), each with id, size, "
"pos and yaw as below; keep placements clear of them and do not block doors; they are not part of the answer.\n"
"Object: id, size [sx,sy,sz] along its local X,Y,Z (local +X = its front), optional desc.\n"
"Output only JSON {\"placements\":[{\"id\":id,\"on\":id,\"pos\":[x,y,z],\"yaw\":deg},...]}, "
"every id once, in the listed order. on: the object it stands on, z = that object's top; "
"omit on for the floor, z = 0. pos: bottom center of the box. "
"yaw: integer degrees in [0,360), counter-clockwise from room +X to the object's front.\n"
"constraints (optional, all must hold): [\"on\",a,b]; [\"faces\",a,b] a's front within 30 degrees of the "
"direction to b's center; [\"near\",a,b,d] footprint gap at most d; [\"against_wall\",a] one whole side of "
"a's footprint within 0.1 of a wall; [\"between\",a,b,c] the segment between the centers of b and c crosses a."
)
HASH = re.compile(r"(?=[a-f]*\d)[0-9a-f]{8}\b") # SAGE asset hashes: glassnightstand5c8f77c6
GENERIC = re.compile(r"^(other\w*|objects?|unknown)( |$)") # labels that say nothing: shown as "object"
UNKNOWN_ROOM = {"misc", "other", "other room", "unknown", "undefined", "none"}
REL = re.compile(r"[,;]?\s*\b(positioned|placed|resting|located|situated|sitting on|standing on|lying on|leaning "
r"against|next to|beside|in front of|on top of|on a flat surface|on the floor)\b.*$", re.I)
DANGLING = {"a", "an", "the", "and", "or", "with", "of", "in", "on", "to", "for", "at", "by", "featuring", "its"}
# labels whose first synonym is a bare modifier ('corner', 'three seat') or an Infinigen factory name
ALIASES = {"corner/side table": "side table", "three-seat / multi-seat sofa": "three seat sofa",
"three-seat / multi-person sofa": "three seat sofa", "coffeetablefactory": "coffee table",
"sidetablefactory": "side table", "boxcomforterfactory": "comforter", "comforterfactory": "comforter"}
def norm_cat(c):
"""Traceable category normalization (raw label stays in IR): first synonym, words only, no hash/instance no."""
c = (c or "").lower()
c = ALIASES.get(re.sub(r"\(.*", "", c).strip(), c)
c = re.split(r"[,(/]", c)[0]
c = " ".join(re.sub(r"[^a-z0-9]+", " ", c).split())
c = " ".join(HASH.sub(" ", c).split()).split(" or ")[0]
return re.sub(r"(?<=[a-z])(?:\s*\d+)+$", "", c)
def head(o):
"""Head noun of an object's category (last word), used by anchor rules and validator buckets."""
w = norm_cat(o["category"]).split()
return w[-1] if w else ""
ROOM_WORDS = {"livingroom": "living room", "diningroom": "dining room", "livingdiningroom": "living dining room",
"masterbedroom": "master bedroom", "secondbedroom": "second bedroom", "kidsroom": "kids room",
"storageroom": "storage room", "laundryroom": "laundry room", "elderlyroom": "elderly room"}
def norm_room_type(t):
if not t:
return None
t = re.sub(r"\s*/\s*", "/", " ".join(t.lower().replace("_", " ").split())).rstrip(".").strip()
t = " ".join(ROOM_WORDS.get(w, w) for w in t.split())
t = re.sub(r"(?:\s+\d+)+$", "", t) # "meeting room 1" -> "meeting room"
# floor / apartment / capacity tags of MansionWorld: "restroom f1", "apartment 2 storage", "meeting room 4p"
t = " ".join(re.sub(r"\b(?:f\d+|apt\d+|apartment \d+|\d+p)\b", " ", t).split())
t = re.sub(r"\brestrooms\b", "restroom", t)
return None if not t or t in UNKNOWN_ROOM else t
def num(x):
"""2-decimal number as shown to the model; integral values without '.0', no '-0'."""
v = round(float(x), 2) + 0.0
return int(v) if v == int(v) else v
def shown_size(o):
return [max(num(v), 0.01) for v in o["size"]]
def short_desc(d, max_words=16):
"""First sentence, no article, no placement clause (scene context, not asset), cut at a clause boundary."""
s = re.split(r"(?<=[a-z])\.\s", d.strip())[0].rstrip(".")
s = re.sub(r"^(a|an|the)\s+", "", s, flags=re.I)
w = REL.sub("", s).strip(" ,;").split()
if len(w) > max_words:
w = w[:max_words]
cut = [i for i, x in enumerate(w) if i >= 4 and (x.endswith(",") or x.lower() in ("with", "featuring", "and"))]
if cut:
w = w[:cut[-1] + 1] if w[cut[-1]].endswith(",") else w[:cut[-1]]
while w and w[-1].lower().strip(",") in DANGLING:
w.pop()
return " ".join(w).strip(" ,;")
def _clean_once(b):
p = Polygon([(round(x, 2), round(y, 2)) for x, y in b]).simplify(0.01, preserve_topology=True)
if not p.is_valid and p.area > 0: # cm rounding can pinch a valid ring into a self-touch: repair if harmless
q = p.buffer(0)
if q.geom_type == "Polygon" and not q.interiors and abs(q.area - p.area) < 0.01 * p.area:
p = q
if not p.is_valid or p.area <= 0:
return None
pts = list(orient(p, 1.0).exterior.coords)[:-1]
k = min(range(len(pts)), key=lambda i: (pts[i][0] ** 2 + pts[i][1] ** 2, pts[i]))
return [[num(x), num(y)] for x, y in pts[k:] + pts[:k]]
def clean_boundary(b):
"""cm rounding, duplicate/collinear removal (<=1 cm deviation), CCW, start at the vertex nearest (0,0).
Repeated until nothing changes (simplify never drops a ring's start vertex, and the start moves), so it is
idempotent: a request echoing a clean boundary keeps it."""
out = _clean_once(b)
for _ in range(8):
nxt = _clean_once(out) if out else None
if nxt is None or nxt == out:
break
out = nxt
return out
def rot90(room, k):
"""Exact augmentation on cm-rounded rooms: rotate about the origin by k*90 deg, shift back to the min corner.
Run BEFORE canonical."""
if k % 4 == 0:
return room
c, s = [(1, 0), (0, 1), (-1, 0), (0, -1)][k % 4]
rot = lambda x, y: (c * x - s * y, s * x + c * y)
b = [rot(x, y) for x, y in room["boundary"]]
mx, my = min(p[0] for p in b), min(p[1] for p in b)
b = [[num(x - mx), num(y - my)] for x, y in b] # rotation keeps CCW; only the start vertex moves
s0 = min(range(len(b)), key=lambda i: (b[i][0] ** 2 + b[i][1] ** 2, b[i]))
def turn(o):
x, y = rot(*o["pos"][:2])
return {**o, "pos": [num(x - mx), num(y - my), o["pos"][2]], "yaw": (o["yaw"] + k * math.pi / 2) % (2 * math.pi)}
out = {**room, "boundary": b[s0:] + b[:s0], "objects": [turn(o) for o in room["objects"]]}
if room.get("fixed"):
out["fixed"] = [turn(o) for o in room["fixed"]]
return out
def slug(category):
nc = norm_cat(category)
return "object" if not nc or GENERIC.match(nc) else nc.replace(" ", "_")
def canonical(room):
"""Order by footprint area desc (ties: category, size); identical instances by (z, x, y) when poses exist
(training), else keep input order (inference; the sort is stable). ids become slug(norm_cat)_k, counted over
the fixed items first, then the objects, so no id is shared between the two lists."""
def key(o):
s = [max(round(v, 2), 0.01) for v in o["size"]] # = shown_size (training sizes are already shown_size)
k = (-round(s[0] * s[1], 4), norm_cat(o["category"]), [-v for v in s])
return k + ((round(o["pos"][2], 2), round(o["pos"][0], 2), round(o["pos"][1], 2)) if "pos" in o else ())
fixed = sorted(room.get("fixed") or [], key=key)
objs = sorted(room["objects"], key=key)
count, new = {}, {}
for o in fixed + objs:
sl = slug(o["category"])
count[sl] = count.get(sl, 0) + 1
new[o["id"]] = f"{sl}_{count[sl]}"
out = {**room, "objects": [{**o, "id": new[o["id"]], "parent": new.get(o.get("parent"))} for o in objs]}
if fixed:
out["fixed"] = [{**o, "id": new[o["id"]]} for o in fixed]
return out
def user_json(room, constraints=None, with_desc=True):
d = {}
rt = norm_room_type(room.get("room_type"))
if rt:
d["room_type"] = rt
d["boundary_type"] = room["boundary_type"]
d["boundary"] = room["boundary"]
if room.get("height"):
d["height"] = num(room["height"])
if room.get("fixed"):
d["fixed"] = [{"id": o["id"], "size": shown_size(o), "pos": [num(v) for v in o["pos"]],
"yaw": int(round(math.degrees(o["yaw"]))) % 360} for o in room["fixed"]]
objs = []
for o in room["objects"]:
e = {"id": o["id"], "size": shown_size(o)}
desc = short_desc(o["desc"]) if with_desc and o.get("desc") else ""
if desc: # a desc that is only a placement clause shortens to ""
e["desc"] = desc
objs.append(e)
d["objects"] = objs
if constraints:
d["constraints"] = constraints
return json.dumps(d, ensure_ascii=False, separators=(",", ":"))
def target_json(room):
out = []
for o in room["objects"]:
p = {"id": o["id"]}
if o.get("parent"):
p["on"] = o["parent"]
p["pos"] = [num(v) for v in o["pos"]]
p["yaw"] = int(round(math.degrees(o["yaw"]))) % 360
out.append(p)
return json.dumps({"placements": out}, ensure_ascii=False, separators=(",", ":"))
def messages(room, constraints=None, with_desc=True, with_target=True):
m = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_json(room, constraints, with_desc)}]
if with_target:
m.append({"role": "assistant", "content": target_json(room)})
return m
def prompt_text(tok, msgs):
"""Chat-template prompt ending right where the assistant answer starts (Qwen3 thinking off)."""
return tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
def _isnum(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)
MAX_COORD = 1e3
def parse(text, ids):
"""Never raises. -> (placements {id: {pos, yaw (rad), on}}, error | None).
error: truncated_or_bad_json | bad_json | bad_item | bad_number | duplicate_id | missing_ids | extra_ids |
bad_support_ref (unknown, self or cyclic 'on')."""
s = text.split("</think>", 1)[-1] if isinstance(text, str) else ""
i = s.find("{")
try:
items = json.JSONDecoder().raw_decode(s, i)[0]["placements"] if i >= 0 else None
except (ValueError, KeyError, TypeError, IndexError, RecursionError):
return {}, "truncated_or_bad_json"
if not isinstance(items, list):
return {}, "bad_json"
out, err = {}, None
for p in items:
pos, yaw, on = (p.get("pos"), p.get("yaw"), p.get("on")) if isinstance(p, dict) else (None, None, None)
if not (isinstance(p, dict) and isinstance(p.get("id"), str) and isinstance(pos, list) and len(pos) == 3
and all(map(_isnum, pos)) and _isnum(yaw) and (on is None or isinstance(on, str))):
err = err or "bad_item"
continue
try:
vals = [float(v) for v in pos + [yaw]] # OverflowError on absurdly long integers
except OverflowError:
vals = [math.inf]
if not all(math.isfinite(v) and abs(v) < MAX_COORD for v in vals): # 1 km: nothing in a room is further
err = err or "bad_number"
continue
if p["id"] in out:
err = err or "duplicate_id"
continue
out[p["id"]] = {"pos": vals[:3], "yaw": math.radians(vals[3] % 360), "on": on}
if err is None and set(out) != set(ids):
err = "missing_ids" if set(ids) - set(out) else "extra_ids"
if err is None:
for pid, p in out.items():
seen, cur = {pid}, p["on"]
while cur is not None:
if cur not in out or cur in seen:
return out, "bad_support_ref"
seen.add(cur)
cur = out[cur]["on"]
return out, err
def gt_placements(room):
"""Reference layout in parse() form (evaluate --gt-only)."""
return {o["id"]: {"pos": o["pos"], "yaw": o["yaw"], "on": o.get("parent")} for o in room["objects"]}
def apply(room, pl):
"""Predicted layout in IR form; parents come ONLY from the prediction; missing objects are dropped."""
return {**room, "objects": [{**o, "pos": pl[o["id"]]["pos"], "yaw": pl[o["id"]]["yaw"],
"parent": pl[o["id"]]["on"]} for o in room["objects"] if o["id"] in pl]}
def footprint(o):
"""Oriented footprint corners [(x, y)] x4 of an upright object."""
c, s = math.cos(o["yaw"]), math.sin(o["yaw"])
hx, hy = o["size"][0] / 2, o["size"][1] / 2
x, y = o["pos"][0], o["pos"][1]
return [(x + c * dx - s * dy, y + s * dx + c * dy) for dx, dy in ((hx, hy), (-hx, hy), (-hx, -hy), (hx, -hy))]
if __name__ == "__main__":
assert norm_cat("toilet, can, commode") == "toilet" and norm_cat("ceiling lamp(traffic light)") == "ceiling lamp"
assert norm_cat("pen_1") == "pen" == norm_cat("pen2") and norm_cat("2 seat sofa") == "2 seat sofa"
assert norm_cat("Lounge Chair / Cafe Chair") == "lounge chair" and norm_cat("King-size Bed") == "king size bed"
assert norm_cat("glassnightstand5c8f77c6") == "glassnightstand" and norm_cat("plant_or_flower_pot") == "plant"
assert norm_room_type("Bedroom / Hotel") == "bedroom/hotel" and norm_room_type("Misc.") is None
assert norm_room_type("LivingRoom") == "living room" == norm_room_type("living_room")
assert norm_room_type("Meeting Room 1") == "meeting room"
assert parse('{"placements":[{"id":"a","pos":[' + '9' * 400 + ',1,0],"yaw":0}]}', ["a"])[1] == "bad_number"
assert short_desc("A wooden table positioned next to a rectangular object. It is old.") == "wooden table"
assert num(0.0) == 0 and num(-0.001) == 0 and num(2.704) == 2.7
assert clean_boundary([[0, 0], [2, 0], [4, 0], [4, 3], [4, 3], [0, 3]]) == [[0, 0], [4, 0], [4, 3], [0, 3]]
wob = [[0, 0], [2.004, 0.006], [4, 0], [4.006, 1.504], [4, 3], [0, 3]] # near-collinear after cm rounding
assert clean_boundary(clean_boundary(wob)) == clean_boundary(wob)
ok = '{"placements":[{"id":"desk_1","pos":[1,1,0],"yaw":90},{"id":"lamp_1","on":"desk_1","pos":[1,1,0.75],"yaw":0}]}'
ids = ["desk_1", "lamp_1"]
pl, err = parse("<think>\n\n</think>\n\n" + ok + "\nnote {x}", ids)
assert err is None and abs(pl["desk_1"]["yaw"] - math.pi / 2) < 1e-9 and pl["lamp_1"]["on"] == "desk_1"
for bad, want in (('"on":"lamp_1"', "bad_support_ref"), ('"on":"sofa_1"', "bad_support_ref"),
('"on":["desk_1"]', "bad_item"), ('"on":{"id":"desk_1"}', "bad_item")):
assert parse(ok.replace('"on":"desk_1"', bad), ids)[1] == want, bad
assert parse(ok.replace('"yaw":0}', '"yaw":NaN}'), ids)[1] == "bad_number"
assert parse(ok.replace('"yaw":90', '"yaw":true'), ids)[1] == "bad_item"
assert parse(ok.replace('"pos":[1,1,0]', '"pos":"110"'), ids)[1] == "bad_item"
assert parse('{"placements":' + "[" * 30000 + "]" * 30000 + "}", ids)[1] in ("truncated_or_bad_json", "bad_item")
assert parse(ok[:70], ids)[1] == "truncated_or_bad_json" and parse(None, ids)[1] == "bad_json"
assert parse('{"placements":[{"id":"desk_1","pos":[1,1,0],"yaw":0}]}', ids)[1] == "missing_ids"
room = {"room_type": "Bed_Room", "boundary_type": "polygon", "boundary": [[0, 0], [4, 0], [4, 3], [0, 3]], "height": 2.7,
"objects": [{"id": s, "category": "chair", "size": [.5, .5, .9], "pos": [x, y, 0], "yaw": 0}
for s, x, y in (("a", 1, 1), ("b", 3, 1), ("c", 2, 2))]
+ [{"id": "d", "category": "desk", "size": [1.2, .6, .75], "pos": [2, .3, 0], "yaw": math.pi / 2},
{"id": "l", "category": "lamp", "size": [.2, .2, .4], "pos": [2, .3, .75], "yaw": 0, "parent": "d"}]}
r4 = room
for k in range(4): # rot90 before canonical keeps the (z, x, y) numbering of identical chairs
c = canonical(rot90(room, k))
chairs = [o for o in c["objects"] if o["category"] == "chair"]
assert [o["pos"][:2] for o in chairs] == sorted(o["pos"][:2] for o in chairs), k
r4 = rot90(r4, 1)
assert r4["boundary"] == room["boundary"] and [o["pos"] for o in r4["objects"]] == [o["pos"] for o in room["objects"]]
c = canonical(room)
assert [o["id"] for o in c["objects"]] == ["desk_1", "chair_1", "chair_2", "chair_3", "lamp_1"]
m = messages(c)
assert json.loads(m[1]["content"])["room_type"] == "bed room" and "fixed" not in json.loads(m[1]["content"])
# fixed boxes: shared id namespace (a fixed chair takes chair_1), shown with pose, rotated with the room, not answered
fx = {**room, "fixed": [{"id": "col", "category": "column", "size": [.3, .3, 2.7], "pos": [3.5, 2.5, 0], "yaw": 0},
{"id": "fc", "category": "chair", "size": [.5, .5, .9], "pos": [.5, .5, 0], "yaw": 0},
{"id": "u", "category": "unknown", "size": [1, .4, .8], "pos": [2, 2.8, 0], "yaw": math.pi / 2}]}
cf = canonical(fx)
assert [o["id"] for o in cf["fixed"]] == ["object_1", "chair_1", "column_1"], cf["fixed"]
assert [o["id"] for o in cf["objects"]] == ["desk_1", "chair_2", "chair_3", "chair_4", "lamp_1"]
uj = json.loads(user_json(cf))
assert uj["fixed"][1] == {"id": "chair_1", "size": [0.5, 0.5, 0.9], "pos": [0.5, 0.5, 0], "yaw": 0} and "fixed" not in target_json(cf)
r1 = rot90(fx, 1) # (3.5, 2.5) turned by 90 deg about the origin -> (-2.5, 3.5) -> shifted
assert r1["fixed"][0]["pos"] == [0.5, 3.5, 0] and abs(r1["fixed"][0]["yaw"] - math.pi / 2) < 1e-9, r1["fixed"][0]
assert slug("otherprop") == "object" == slug("") == slug("Objects") and slug("floor lamp") == "floor_lamp"
pl, err = parse(m[2]["content"], [o["id"] for o in c["objects"]])
assert err is None and pl["lamp_1"]["on"] == "desk_1" and pl["lamp_1"]["pos"] == [2.0, 0.3, 0.75]
assert apply(c, pl)["objects"][-1]["parent"] == "desk_1"
fp = footprint(c["objects"][0]) # desk rotated 90 deg: 0.6 wide in x, 1.2 deep in y
xs, ys = [p[0] for p in fp], [p[1] for p in fp]
assert abs(max(xs) - min(xs) - 0.6) < 1e-9 and abs(max(ys) - min(ys) - 1.2) < 1e-9
print("scene.py self-check ok")
|