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: 77,862 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 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 | """All-request, stage-separated evaluation for structured and text v2 models.
Besides the reference errors the report carries label-only baselines (room centre,
per-category mean position, per-category median size, uniform yaw), mode-collapse
diagnostics (every predicted object, the label-complete ground truth, and the predicted
objects matched to it, each with the out-of-room fraction), symmetry-aware yaw error counts per order, and a per-source
breakdown of all of the above.
``--projection full minimal`` also evaluates the three-field projection
(``project_minimal``: ``batch.render_minimal_condition`` with the training
augmentation's regrouping) over boundary-known axis-aligned rectangular rooms; the report's top
level is the first projection and ``projections`` holds the others. Objects whose
``validity.size_axis_swap_allowed`` is true score size and yaw under the
box-equivalent minimum over (sx, sy, yaw + k pi/2 with sx/sy swapped for odd k),
with the plain-convention errors reported beside them. Target validation is
counted per check code as pass / violation / unknown. ``max_length`` comes from the
checkpoint (``io.load_checkpoint_config``) unless given explicitly; model settings
come from its ``model_config.json``. Reports record the checkpoint, its binding,
and the data and code sha256.
``baselines_paired`` scores the baselines on the model reference's own requests (those with a layout);
``validation`` counts validator collisions and clean rooms (no hard violation) for the layouts and, under the same
validator, for their labels. ``--save-head-outputs DIR`` keeps every request's head outputs;
``--from-head-outputs DIR`` re-decodes and scores them on the CPU with the online result.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict
import json
import os
import math
from pathlib import Path
import time
import numpy as np
from shapely.geometry import Point, Polygon
import torch
from fastfill.v2.batch import (AUGMENT_DEFAULTS, AUGMENT_MAGNITUDES, OBJECT_BUDGET_ERROR, _geometry_rows, augment_sample, collate_samples, minimal_form_eligible,
load_tokenizer, room_normalization)
from fastfill.v2.boxes import bev_iou
from fastfill.v2.geometry import decode_yaw, wrap_yaw
from fastfill.v2.io import fingerprint, load_checkpoint_config, read_samples, run_metadata, safe_output, to_device
from fastfill.v2.matching import match_batch
from fastfill.v2.model import load_model, model_inputs
from fastfill.v2.schema import migrate_legacy_row, validate_condition, validate_layout
from fastfill.v2.validation import CHECK_STATUSES, effective_support_requests, footprint, validate_scene, validate_required_levels
BASELINE_METRICS = (("room_center_position", "bottom_center_error_m"), ("category_mean_position", "bottom_center_error_m"),
("category_median_size", "log_size_error"), ("uniform_yaw", "yaw_error_rad"))
REFERENCE_METRICS = ("bottom_center_error_m", "log_size_error", "yaw_error_rad", "bev_iou",
"log_size_error_plain_convention", "yaw_error_rad_plain_convention")
COLLAPSE_SCOPE = ("predicted: every requested object (a legal exchange of IDs cannot change it); ground truth: request "
"slots whose labels form a trustworthy upright box (complete position and size, finite yaw); "
"predicted_matched: the predicted objects the reference matching assigns to those slots, the same "
"objects as ground truth, so compare predicted_matched with ground_truth")
COLLAPSE_KEYS = ("objects", "pairs", "bev_overlap_pairs", "same_category_pairs", "stacked_same_category_pairs",
"nearest_wall_distance_sum_m", "central_quarter_objects", "out_of_room_objects",
"footprint_outside_objects", "floor_objects", "door_blocking_objects")
OUT_OF_ROOM_M = .05 # bottom centre this far outside the floor polygon counts as out of the room
FOOTPRINT_VIOLATION_FRACTION = .1 # footprint share outside the room / inside a door zone that counts as a violation
DOOR_ZONE_DEPTH_M = .8 # ``cell_costs.door_zones`` depth used by door_blocking_objects
PROJECTIONS = ("full", "minimal")
# augment_sample with only the minimal form enabled is the K5 projection of a row: shared condition
# renderer, floor declarations dropped (z learned), exchangeable groups recomputed by the build rule.
MINIMAL_PROJECTION = {key: (1. if key == "minimal_form_p" else 0. if key.endswith("_p") or key in AUGMENT_MAGNITUDES else False)
for key in AUGMENT_DEFAULTS}
DEFAULT_MAX_LENGTH = 4096
def project_minimal(sample):
"""The three-field projection of an evaluation row, or None unless ``batch.minimal_form_eligible``
(a boundary-known axis-aligned rectangle; such rows count as ``skipped_non_rectangular_rooms``)."""
sample = migrate_legacy_row(sample)
if not minimal_form_eligible(sample["condition"]["room"]):
return None
return augment_sample(sample, MINIMAL_PROJECTION, torch.Generator().manual_seed(0))
def collapse_score(counts):
"""Collapse score: BEV overlap rate (IoU > 0.3) + central-quarter fraction; lower is better.
No objects -> None; objects without pairs contribute only the central fraction.
It cannot see a layout pushed against or outside the walls (such a layout scores 0):
read it only together with ``out_of_room_fraction`` and the reference errors.
"""
if not counts["objects"]:
return None
return (counts["bev_overlap_pairs"] / counts["pairs"] if counts["pairs"] else 0.) + counts["central_quarter_objects"] / counts["objects"]
def batch_collapse_counts(predictions, batch, assignment=None):
"""Summed ``_collapse_counts`` of a collated batch, as the matching ``collapse_metrics`` column.
Model outputs (yaw logits) are the predicted column: every request slot; with
``assignment`` (prediction slot -> label slot, e.g. the criterion's) only the slots
assigned to label-complete ones, the predicted_matched column. Labels passed with
their yaw in radians as ``predictions["yaw"]`` are the ground-truth column: only the
label-complete slots.
"""
origin, scale = batch["origin"].detach().float().cpu(), batch["scale"].detach().float().cpu()
position = (predictions["position_normalized"].detach().float().cpu() * scale[:, None] + origin[:, None]).tolist()
size = predictions["size"].detach().float().cpu().tolist()
labels = "yaw" in predictions
yaw = (predictions["yaw"].detach().float() if labels else decode_yaw(
predictions["yaw_logits"].detach().float(), predictions["yaw_residuals"].detach().float())).cpu().tolist()
complete = (batch["slot_mask"] & batch["validity"]["position"].all(-1) & batch["validity"]["size"].all(-1)
& torch.isfinite(batch["targets"]["yaw"])).cpu()
keep = complete if labels else batch["slot_mask"].cpu() if assignment is None else complete.gather(1, assignment.cpu())
totals = dict.fromkeys(COLLAPSE_KEYS, 0)
for b, objects in enumerate(batch["objects"]):
slots = keep[b].nonzero().flatten().tolist()
counts = _collapse_counts([(position[b][i], size[b][i], yaw[b][i]) for i in slots], [objects[i]["category"] for i in slots],
batch["conditions"][b]["room"], origin[b].tolist(), scale[b].tolist())
for key in totals:
totals[key] += counts[key]
return totals
GRID_DECODES = ("spread", "argmax")
PARENT_LATTICE = np.stack(np.meshgrid(*[np.linspace(-1, 1, 5)] * 2, indexing="ij"), -1).reshape(-1, 2)
def spread_grid_xy(logits, residuals, grid, size, yaw, position, room, *, requests=None, fixed_z=None, yaw_logits=None,
yaw_residuals=None, keep_yaw=None, top_k=64, max_overlap=.15, margin=.02, near_wall=.3, hang_centre=1.5,
inner=.05):
"""Collision-aware decoding of one scene's grid head (meters in, meters out); returns (xy, yaw, z).
``room`` may list ``fixed_objects`` (condition rows: bottom_center_m, size_local_m, yaw_rad): they never move,
block cells like placed objects and can carry raised ones. ``requests`` (the slots' effective requests,
``validation.effective_support_requests``) supply ``id`` and ``support_parent``; ``fixed_z`` marks slots whose
z the condition fixes (``fixed_position_mask[..., 2]``). Without them every object is undeclared.
Objects are placed floor-standing before raised ones, parents before their declared children, largest
footprint first; each takes its most probable cell whose footprint stays in the room (a footprint at most
``margin`` past a wall slides back inside first) and overlaps no placed
or fixed object that shares its height interval (overlap / smaller footprint <= max_overlap). Identical
requests, whose argmax cells coincide, therefore spread over their next most probable cells. If no
candidate qualifies, the least overlapping one is used (for a declared child: on its parent).
Declarations beat the predicted height: an object whose z is fixed stands at that z, one declared on
"floor" stands on the floor, one declared on "wall" hangs at its rule height (below), and one declared on a request
or fixed object stands on the parent's top: at its most probable top_k cell whose centre lies inside the
parent's (placed) footprint, else at the free point of a 5 x 5 lattice over that footprint (inset by its
own half extents) nearest its argmax decode, else at the least overlapping of these, so the declared
support holds even at the cost of a collision. A declared child whose predicted bottom lies more than ``inner``
below its parent's top (and not below its bottom) stands inside the parent instead, e.g. a book on a bookcase's
inner shelf: it keeps that height (at least the parent's bottom) and the parent is no obstacle to it.
A "wall" object is placed by rule: each top_k cell is projected
onto the wall (side of the room's bounds) nearest that cell, back flush with it and facing into the room
(local +X into the room, as every source wall object; a box predicted thinner along local Y with local +X along
the wall nearest its argmax cell keeps that axis, turned a quarter; a ``keep_yaw`` slot keeps its yaw),
sliding along the wall to stay inside; the first projection free of
overlap wins (wall, floor and fixed objects sharing its height interval are obstacles), else the least
overlapping one; one wider or deeper than the room is centred along that axis. It keeps its predicted z when
that is over 0.15 m above the floor, else (a z near the floor is no evidence here) hangs centred ``hang_centre``
above the floor; either is clamped to [floor, ceiling - its height] when ``room`` gives ``height_m`` (else only
>= floor), and it is placed with the raised objects when it ends over 0.15 m above the floor.
With yaw logits (they only switch the rule on), a floor-standing object whose candidate footprint ends within
``near_wall`` of a wall turns its back to that wall (as 97-99% of wall-adjacent training furniture) only by a
half turn: its decoded ``yaw`` flips by pi when the flipped yaw faces away from that wall within 45 degrees, else
it is kept. The model learns yaw only modulo pi (training takes the min over {y, y + pi}), so the flip chooses
between the two facings of the predicted box and never turns the box: it is never rotated by a quarter turn and its
footprint stays the same. By design it also turns an object that faced the wall on purpose (a chair facing a desk
against it; symmetric yaw metrics do not see this), and only the nearest wall counts: in a corner, an object facing
the second-nearest wall keeps facing it. Slots marked in ``keep_yaw`` (one bool per slot; ``serialize_predictions``
marks objects whose orientation the request constrains) never turn: they keep ``yaw`` and their footprints are
checked at it.
An undeclared raised object (predicted bottom > 0.15 m above the floor) whose argmax cell lies over no placed or
fixed floor-standing object holding its predicted bottom (from that object's bottom to 0.15 m over its top), and
whose argmax footprint ends within ``near_wall`` of a wall, hangs there: it keeps its predicted z (clamped like a
"wall" object's) at its most probable free cell (a painting over a sofa, a mirror on a bare wall; no declaration
certifies the wall, so it is not projected onto it). Any other undeclared raised object rests on a
floor-standing object: it takes its most probable cell (at least 10% as likely as its best) whose centre lies inside
such an object's footprint and sits on the highest such top; with no such cell it goes to the floor at its
most probable free cell. "Inside a footprint" (here and for declared parents) is tested in the supporting box's
own frame, so a rotated table holds only points over its rotated top, and the parent lattice is laid out in it.
"""
# ponytail: collisions between rotated footprints are compared by their axis-aligned bounds (exact for 90-degree
# yaws, conservative otherwise); switch to shapely polygons if oblique furniture matters.
n, cells = logits.shape
index = torch.arange(cells)
centres = torch.stack((torch.div(index, grid, rounding_mode="floor"), index % grid), -1).double()
xy_norm = (centres + .5) / grid + residuals.double() / (2 * grid) # n x cells x 2
origin, scale = np.asarray(room["origin"], float), np.asarray(room["scale"], float)
xy = xy_norm.numpy() * scale[:2] + origin[:2]
size, yaw, position = np.asarray(size, float).reshape(-1, 3), np.asarray(yaw, float), np.asarray(position, float)
requests = [{}] * n if requests is None else list(requests)
fixed_z = np.zeros(n, bool) if fixed_z is None else np.asarray(fixed_z, bool)
keep_yaw = np.zeros(n, bool) if keep_yaw is None else np.asarray(keep_yaw, bool)
if len(requests) != n or fixed_z.shape != (n,) or keep_yaw.shape != (n,):
raise ValueError("requests, fixed_z and keep_yaw need one entry per slot")
def halves(angle, box):
c, s = np.abs(np.cos(angle)), np.abs(np.sin(angle))
return .5 * np.stack((c * box[..., 0] + s * box[..., 1], s * box[..., 0] + c * box[..., 1]), -1)
def within(points, centres, box, angle): # k points x m boxes: inside each box's footprint, in the box's own frame
d = points[:, None] - centres[None]
c, s = np.cos(angle), np.sin(angle)
return ((np.abs(c * d[..., 0] + s * d[..., 1]) <= box[:, 0] / 2)
& (np.abs(c * d[..., 1] - s * d[..., 0]) <= box[:, 1] / 2))
fixed = room.get("fixed_objects", ())
fixed_ids = {obj["id"]: j for j, obj in enumerate(fixed)}
fixed_xy = np.array([obj["bottom_center_m"][:2] for obj in fixed], float).reshape(-1, 2)
fixed_bottom = np.array([obj["bottom_center_m"][2] for obj in fixed], float)
fixed_size = np.array([obj["size_local_m"] for obj in fixed], float).reshape(-1, 3)
fixed_yaw = np.array([obj["yaw_rad"] for obj in fixed], float)
fixed_half = halves(fixed_yaw, fixed_size)
fixed_top, fixed_area = fixed_bottom + fixed_size[:, 2], 4 * fixed_half.prod(-1)
fixed_standing = fixed_bottom - room["floor_z"] <= .15
slots = {r["id"]: i for i, r in enumerate(requests) if "id" in r}
parent = [r.get("support_parent") for r in requests]
if any(p is not None and p not in slots and p not in fixed_ids and p not in ("floor", "wall") for p in parent):
raise ValueError("unknown support parent")
def depth(i):
chain = 0
while parent[i] in slots:
i, chain = slots[parent[i]], chain + 1
if chain > n:
raise ValueError("support graph contains a cycle")
return chain
half = halves(yaw, size)
area = 4 * half[:, 0] * half[:, 1]
low = position[:, 2]
lo, hi = np.asarray(room["bounds"][0], float), np.asarray(room["bounds"][1], float)
on_object = np.array([p in slots or p in fixed_ids for p in parent], bool)
on_floor = fixed_z | np.array([p == "floor" for p in parent], bool)
on_wall = np.array([p == "wall" for p in parent], bool)
ceiling = room.get("height_m")
hung = np.where(low - room["floor_z"] > .15, low, room["floor_z"] + hang_centre - size[:, 2] / 2)
hung = np.maximum(hung if ceiling is None else np.minimum(hung, room["floor_z"] + ceiling - size[:, 2]), room["floor_z"])
raised = on_object | (~on_floor & (np.where(on_wall, hung, low) - room["floor_z"] > .15))
stand = np.where(fixed_z, low, np.where(on_wall, hung, float(room["floor_z"]))) # floor-standing or wall-hung z
order = sorted(range(n), key=lambda i: (bool(raised[i]), depth(i), -area[i]))
# stable: equal logits rank by cell index on every platform (an unstable sort ordered them differently on aarch64)
ranked = torch.sort(logits.double(), dim=-1, descending=True, stable=True).indices[:, :top_k].numpy()
chosen, chosen_yaw, chosen_z = xy[np.arange(n), ranked[:, 0]].copy(), yaw.copy(), low.copy()
away = np.array([0., np.pi, np.pi / 2, -np.pi / 2]) # from walls x=lo, x=hi, y=lo, y=hi into the room
flipped = (yaw + 2 * np.pi) % (2 * np.pi) - np.pi # the decoded yaw turned by pi, wrapped: the same box
def boxes(js, f=slice(None)): # placed slots js, then fixed objects f: centre, half extents, bottom, top, footprint
js = np.asarray(js, int) # area, local size, yaw
return (np.concatenate((chosen[js], fixed_xy[f])), np.concatenate((half[js], fixed_half[f])),
np.concatenate((chosen_z[js], fixed_bottom[f])), np.concatenate((chosen_z[js] + size[js, 2], fixed_top[f])),
np.concatenate((area[js], fixed_area[f])), np.concatenate((size[js], fixed_size[f])),
np.concatenate((chosen_yaw[js], fixed_yaw[f])))
placed = []
for i in order:
cand = xy[i, ranked[i]] # k x 2
cand_yaw = np.full(len(cand), yaw[i])
o_xy, o_half, o_bottom, o_top, o_area, o_size, o_yaw = boxes(placed) # placed slots, then every fixed object
inside, hang = None, False # inside: the declared parent's box column this child stands in
if raised[i] and not on_object[i] and not on_wall[i]: # undeclared: on or in a standing object, else a near wall
standing = np.concatenate((~raised[np.asarray(placed, int)], fixed_standing))
holds = (standing & within(cand[:1], o_xy, o_size, o_yaw)[0]
& (o_bottom - margin <= low[i]) & (low[i] <= o_top + .15))
hang = not holds.any() and np.concatenate((cand[0] - half[i] - lo, hi - cand[0] - half[i])).min() <= near_wall
if on_object[i]: # the declared parent's footprint and top; a lattice over that top follows the top_k cells
if parent[i] in slots:
j = slots[parent[i]]
base, base_size, base_yaw, top, column = chosen[j], size[j], chosen_yaw[j], chosen_z[j] + size[j, 2], placed.index(j)
else:
j = fixed_ids[parent[i]]
base, base_size, base_yaw, top, column = fixed_xy[j], fixed_size[j], fixed_yaw[j], fixed_top[j], len(placed) + j
if o_bottom[column] - margin <= low[i] < top - inner: # on an inner shelf: keeps its height in the parent
inside = column
# the lattice in the parent's frame, inset by the child's half extents in that frame; includes the centre
lattice = PARENT_LATTICE * np.maximum(base_size[:2] / 2 - halves(yaw[i] - base_yaw, size[i]), 0)
c, s = np.cos(base_yaw), np.sin(base_yaw)
on_top = base + np.stack((c * lattice[:, 0] - s * lattice[:, 1], s * lattice[:, 0] + c * lattice[:, 1]), -1)
on_top = on_top[np.argsort(((on_top - cand[0]) ** 2).sum(-1), kind="stable")] # nearest the argmax decode first
cand, cand_yaw = np.vstack((cand, on_top)), np.append(cand_yaw, np.full(len(on_top), yaw[i]))
elif on_wall[i]: # each cell's nearest wall: back flush with it, facing into the room, slid inside along it
wall = np.stack((cand[:, 0] - lo[0], hi[0] - cand[:, 0], cand[:, 1] - lo[1], hi[1] - cand[:, 1]), -1).argmin(-1)
turn = yaw[i] - away[wall[0]] # a box predicted with its thin local Y across its argmax wall keeps that axis
turn = np.sign(np.sin(turn)) * np.pi / 2 if size[i, 0] > size[i, 1] and abs(np.sin(turn)) > abs(np.cos(turn)) else 0.
cand_yaw = np.arctan2(np.sin(away[wall] + turn), np.cos(away[wall] + turn)) if turn else away[wall]
cand_yaw = np.where(keep_yaw[i], yaw[i], cand_yaw)
wall_half = halves(cand_yaw, size[i])
cand = np.clip(cand, lo + wall_half, hi - wall_half)
rows, axis = np.arange(len(cand)), wall // 2
cand[rows, axis] = np.where(wall % 2, hi[axis] - wall_half[rows, axis], lo[axis] + wall_half[rows, axis])
cand = np.where(2 * wall_half > hi - lo, (lo + hi) / 2, cand) # wider or deeper than the room: centred
elif yaw_logits is not None and not raised[i] and not keep_yaw[i]:
gaps = np.stack((cand[:, 0] - half[i, 0] - lo[0], hi[0] - cand[:, 0] - half[i, 0],
cand[:, 1] - half[i, 1] - lo[1], hi[1] - cand[:, 1] - half[i, 1]), -1)
facing = (flipped[i] - away[gaps.argmin(-1)] + np.pi) % (2 * np.pi) - np.pi # flipped yaw vs facing away
cand_yaw = np.where((gaps.min(-1) < near_wall) & (np.abs(facing) <= np.pi / 4 + 1e-6), flipped[i], cand_yaw)
cand_half = halves(cand_yaw, size[i]) # k x 2
# a wall projection turns the box; elsewhere the footprint is the decoded one (a flip by pi keeps it: same area)
cand_area = 4 * cand_half[:, 0] * cand_half[:, 1] if on_wall[i] else np.full(len(cand), area[i])
over = (np.maximum(lo + cand_half - cand, 0) + np.maximum(cand + cand_half - hi, 0)).max(-1)
snap = (over > 0) & (over <= margin) # within the margin past a wall: slide back inside (the validator allows 1e-4)
cand = np.where(snap[:, None], np.clip(cand, lo + cand_half, hi - cand_half), cand)
outside = over > margin
if on_object[i]:
z = top if inside is None else max(low[i], o_bottom[inside])
cand_z, supported = np.full(len(cand), z), within(cand, base[None], base_size[None], base_yaw)[:, 0]
else:
cand_z, supported = np.full(len(cand), hung[i] if hang else stand[i]), np.zeros(len(cand), bool)
if raised[i] and not on_object[i] and not on_wall[i] and not hang:
s_xy, _, _, s_top, _, s_size, s_yaw = boxes([j for j in placed if not raised[j]], fixed_standing)
over_top = within(cand, s_xy, s_size, s_yaw) # k x m centre in footprint
highest = np.where(over_top, s_top, -np.inf).max(-1, initial=-np.inf)
supported = np.isfinite(highest)
cand_z = np.where(supported, highest, cand_z)
supported &= (logits[i, ranked[i]] - logits[i, ranked[i, 0]]).numpy() >= -math.log(10) # >= 10% of the best cell
gap = (np.minimum(cand[:, None] + cand_half[:, None], o_xy + o_half)
- np.maximum(cand[:, None] - cand_half[:, None], o_xy - o_half))
inter = np.clip(gap, 0, None).prod(-1)
vertical = (np.minimum(cand_z[:, None] + size[i, 2], o_top) - np.maximum(cand_z[:, None], o_bottom)) > margin
if inside is not None: # the parent it stands in is no obstacle
vertical[:, inside] = False
worst = np.where(vertical, inter / np.minimum(cand_area[:, None], o_area), 0).max(-1, initial=0.)
free = ~outside & (worst <= max_overlap)
ok = np.flatnonzero(free & supported if raised[i] else free)
if not len(ok) and not on_object[i]:
ok = np.flatnonzero(free)
# least overlapping fallback; a declared child only among points on its parent (its lattice always is)
pick = ok[0] if len(ok) else int(np.argmin(np.where(supported | ~on_object[i], worst + outside, np.inf)))
chosen[i], chosen_yaw[i], half[i], area[i], chosen_z[i] = cand[pick], cand_yaw[pick], cand_half[pick], cand_area[pick], cand_z[pick]
placed.append(i)
return chosen, chosen_yaw, chosen_z
def _room_frame(batch, b):
room = batch["conditions"][b]["room"]
polygon = np.asarray(room["floor_polygon_xy_m"], float)
floor = room.get("floor_z_m")
return {"origin": batch["origin"][b].cpu().tolist(), "scale": batch["scale"][b].cpu().tolist(),
"bounds": (polygon.min(0), polygon.max(0)), "floor_z": float(floor) if floor is not None else float(batch["origin"][b, 2]),
"fixed_objects": room.get("fixed_objects", []), "height_m": room.get("height_m")}
def serialize_predictions(predictions, batch, *, grid_decode="spread"):
"""Validated layouts of a collated batch. ``spread`` (grid head only) re-decodes each scene with
``spread_grid_xy`` under its declared supports, fixed objects and fixed z, keeping the decoded yaw of every
object named as ``object_id`` by a "faces_direction" or "faces" constraint (hard or soft); ``argmax`` keeps
the raw head. A ``support_head`` model's ``support_logits`` give every object a ``support_parent``
(``support.decode_support_parents``: declarations kept, the rest predicted), and spread places the undeclared
ones on their predicted parents."""
if grid_decode not in GRID_DECODES:
raise ValueError(f"grid_decode must be one of {GRID_DECODES}")
supports = None
if "support_logits" in predictions:
from fastfill.v2.support import decode_support_parents
supports = [decode_support_parents(predictions["support_logits"][b, :len(objects)].detach().cpu(),
effective_support_requests(batch["conditions"][b]))
for b, objects in enumerate(batch["objects"])]
position = predictions["position_normalized"] * batch["scale"][:, None] + batch["origin"][:, None]
yaw = decode_yaw(predictions["yaw_logits"], predictions["yaw_residuals"])
if grid_decode == "spread" and "position_cell_logits" in predictions:
position, yaw = position.detach().cpu().double().clone(), yaw.detach().cpu().double().clone()
cells = predictions["position_cell_logits"].shape[-1]
grid = math.isqrt(cells)
if grid * grid != cells:
raise ValueError(f"position_cell_logits hold {cells} cells, not a square grid")
for b, objects in enumerate(batch["objects"]):
n = len(objects)
if n:
faced = {c.get("object_id") for c in batch["conditions"][b].get("constraints", ())
if c.get("type") in ("faces_direction", "faces")}
requests = effective_support_requests(batch["conditions"][b])
if supports is not None:
requests = [{**r, "support_parent": parent} for r, parent in zip(requests, supports[b])]
xy, spread_yaw, spread_z = spread_grid_xy(
predictions["position_cell_logits"][b, :n].detach().cpu().float(),
predictions["position_cell_residuals"][b, :n].detach().cpu().float(), grid,
predictions["size"][b, :n].detach().cpu().double().numpy(), yaw[b, :n].detach().cpu().double().numpy(),
position[b, :n].numpy(), _room_frame(batch, b),
requests=requests,
fixed_z=batch["fixed_position_mask"][b, :n, 2].cpu().numpy(),
yaw_logits=predictions["yaw_logits"][b, :n].detach().cpu().float().numpy(),
yaw_residuals=predictions["yaw_residuals"][b, :n].detach().cpu().float().numpy(),
keep_yaw=[obj["id"] in faced for obj in objects])
position[b, :n, :2] = torch.from_numpy(xy)
position[b, :n, 2] = torch.from_numpy(spread_z)
yaw[b, :n] = torch.from_numpy(spread_yaw).to(yaw.dtype)
result = []
for b, objects in enumerate(batch["objects"]):
layout = {"schema_version": "fastfill.v2", "objects": [
{"id": obj["id"], "target_size_local_m": predictions["size"][b, i].detach().cpu().tolist(),
"bottom_center_m": position[b, i].detach().cpu().tolist(),
"yaw_rad": float(wrap_yaw(float(yaw[b, i].detach().cpu()))),
**({"support_parent": supports[b][i]} if supports is not None else {})} for i, obj in enumerate(objects)]}
validate_layout(layout, batch["conditions"][b])
result.append(layout)
return result
@torch.no_grad()
def predict_layout(model, tokenizer, condition, *, max_length=4096, device="cpu", grid_decode="spread", head=None):
"""``head`` (a dict) receives the forward pass's ``head_arrays`` before decoding."""
validate_condition(condition)
batch = to_device(collate_samples([{"condition": condition}], tokenizer,
max_length=max_length, max_objects=model.config.max_objects), device)
model.eval()
predictions = model(**model_inputs(batch))
if head is not None:
head.update(head_arrays(predictions, batch))
return serialize_predictions(predictions, batch, grid_decode=grid_decode)[0]
# What serialize_predictions reads: position_normalized carries the regressed z (and the argmax-decoded XY).
HEAD_KEYS = ("position_normalized", "size", "yaw_logits", "yaw_residuals", "position_cell_logits", "position_cell_residuals")
# What --save-head-outputs records in DIR/manifest.json, and --from-head-outputs reports as head_outputs_manifest.
HEAD_MANIFEST_KEYS = ("checkpoint", "checkpoint_binding", "max_length", "max_length_source", "grid_decode", "data_path",
"data_sha256", "implementation_sha256", "code_commit", "code_dirty")
def head_arrays(predictions, batch):
"""The head outputs of a one-request batch, exact copies of its object slots, plus its slot mask and ids."""
n = len(batch["objects"][0])
return {**{key: predictions[key][0, :n].detach().cpu().numpy() for key in HEAD_KEYS if key in predictions},
"slot_mask": batch["slot_mask"][0, :n].cpu().numpy(), "ids": np.array([o["id"] for o in batch["objects"][0]], dtype=str)}
class StoredFailure(Exception):
"""A request whose saved head outputs record the online failure (no forward pass, e.g. over capacity)."""
def __init__(self, error):
super().__init__(error["message"])
self.error = error
class HeadOutputMismatch(Exception):
"""Saved head outputs that do not belong to the evaluated rows: aborts the run, never a request failure."""
def decode_head_outputs(path, condition, *, grid_decode="spread"):
"""Re-decode one request's ``--save-head-outputs`` file on the CPU: the online ``serialize_predictions`` on the
same arrays and the same condition, so the layout is identical. A file of another condition aborts the run."""
with np.load(path, allow_pickle=False) as stored:
stored = dict(stored)
if json.dumps(json.loads(str(stored["condition"])), sort_keys=True) != json.dumps(condition, sort_keys=True):
raise HeadOutputMismatch(f"{path} holds the head outputs of another condition")
if "error_type" in stored:
raise StoredFailure({"type": str(stored["error_type"]), "message": str(stored["error_message"])})
from fastfill.v2.batch import TinyTokenizer # the condition-derived fields only: origin, scale, fixed z, objects
batch = collate_samples([{"condition": condition}], TinyTokenizer(), max_length=10**8, max_objects=10**6)
if [o["id"] for o in batch["objects"][0]] != stored["ids"].tolist():
raise HeadOutputMismatch(f"{path} holds the head outputs of other request ids")
predictions = {key: torch.from_numpy(stored[key])[None] for key in HEAD_KEYS if key in stored}
return serialize_predictions(predictions, batch, grid_decode=grid_decode)[0]
def _layout_tensors(layout, batch):
"""Float32 prediction tensors in batch slot order; only correspondence matching reads them."""
objects = {o["id"]: o for o in layout["objects"]}
ordered = [objects[o["id"]] for o in batch["objects"][0]]
p = torch.tensor([[o["bottom_center_m"] for o in ordered]], dtype=torch.float32).reshape(1, -1, 3)
s = torch.tensor([[o["target_size_local_m"] for o in ordered]], dtype=torch.float32).reshape(1, -1, 3)
return {"position_normalized": (p - batch["origin"][:, None]) / batch["scale"][:, None],
"size": s, "slot_mask": batch["slot_mask"]}
def _stat(values):
return {"mean": float(np.mean(values)) if values else None, "valid_objects": len(values)}
def _pool(entries):
"""Label-weighted mean of per-request {"mean", "valid_objects"} measurements."""
count = sum(e["valid_objects"] for e in entries)
total = sum(e["mean"] * e["valid_objects"] for e in entries if e["mean"] is not None)
return {"mean": total / count if count else None, "valid_objects": count}
def _yaw_error(predicted, label, order):
period = 2 * math.pi / order
return abs((predicted - label + period / 2) % period - period / 2)
def _log_size_error(predicted, label):
return float(np.abs(np.log(predicted) - np.log(label)).mean())
def _box_equivalent_errors(p, t, size_valid, yaw_valid):
"""(size, yaw) errors of the label rewriting (sx, sy, sz, yaw + k pi/2), sy/sx swapped for odd k, k = 0..3,
whose summed valid errors are smallest; the same candidate set as the training loss. None for invalid fields."""
best = None
for k in range(4):
sx, sy, sz = t["target_size_local_m"] if size_valid else (1., 1., 1.)
size = _log_size_error(p["target_size_local_m"], (sy, sx, sz) if k % 2 else (sx, sy, sz)) if size_valid else None
yaw = _yaw_error(p["yaw_rad"], t["yaw_rad"] + k * math.pi / 2, 1) if yaw_valid else None
if best is None or (size or 0.) + (yaw or 0.) < (best[0] or 0.) + (best[1] or 0.):
best = (size, yaw)
return best
def _uniform_yaw_error(order, swap, size=None):
"""Expected ``reference_metrics`` yaw error of a uniformly random yaw with a perfect size (the label's own;
``size`` None when it is not valid): pi / (2 order) modulo the symmetry order, and for a swap object the yaw of
``_box_equivalent_errors``' joint minimum, in closed form: with a = the yaw error modulo pi (uniform on
[0, pi/2]) the quarter turn, error pi/2 - a plus its swap cost c (0 without a valid size), wins when a > m."""
if not swap:
return math.pi / (2 * order)
c = _log_size_error(size, (size[1], size[0], size[2])) if size is not None else 0.
m = min(math.pi / 2, math.pi / 4 + c / 2)
return (m * m + (math.pi / 2 - m) ** 2) / math.pi # pi / 8 for a square footprint, pi / 4 once c >= pi / 2
def _matched(layout, sample, hungarian):
"""The one-request batch and its legal assignment (prediction slot -> label slot)."""
from fastfill.v2.batch import TinyTokenizer
batch = collate_samples([sample], TinyTokenizer(), max_length=10**8, max_objects=10**6)
return batch, match_batch(_layout_tensors(layout, batch), batch, enabled=hungarian)[0].tolist()
def reference_metrics(layout, sample, *, hungarian=True, include_iou=True):
"""Reference errors follow only legal correspondence; no GT used for inference.
Errors are computed in float64 metres from the raw layout and target rows, so an
exact imitation of the labels scores exactly zero. Groups come from collate's
``batch["exchangeable_group"]``; match_batch keeps position-incomplete groups at
fixed identity and matches size-incomplete groups on position alone.
``log_size_error`` / ``yaw_error_rad`` use the box-equivalent minimum for objects
with ``size_axis_swap_allowed``; the ``*_plain_convention`` keys keep the label as
written (yaw modulo its symmetry order; modulo pi for swap objects, whose order 4
only stands for the swap) for every object.
"""
batch, assignment = _matched(layout, sample, hungarian)
objects = batch["objects"][0]
predicted = {o["id"]: o for o in layout["objects"]}
labels = {o["id"]: o for o in sample["target"]["objects"]}
valid = batch["validity"]
values = {key: [] for key in REFERENCE_METRICS}
by_order, box_equivalent = {}, 0
for i, j in enumerate(assignment[:len(objects)]):
p, t = predicted[objects[i]["id"]], labels.get(objects[j]["id"], {})
pv, sv, yv = bool(valid["position"][0, j].all()), bool(valid["size"][0, j].all()), bool(valid["yaw"][0, j])
if pv:
values["bottom_center_error_m"].append(float(np.linalg.norm(np.subtract(p["bottom_center_m"], t["bottom_center_m"]))))
order, swap = int(batch["yaw_symmetry_order"][0, j]), bool(batch["size_axis_swap_allowed"][0, j])
size_error = _log_size_error(p["target_size_local_m"], t["target_size_local_m"]) if sv else None
# A swap object's order 4 encodes the axis swap; as written, its box only repeats every pi.
yaw_error = _yaw_error(p["yaw_rad"], t["yaw_rad"], 2 if swap else order) if yv else None
values["log_size_error_plain_convention"] += [size_error] if sv else []
values["yaw_error_rad_plain_convention"] += [yaw_error] if yv else []
if (sv or yv) and swap:
size_error, yaw_error = _box_equivalent_errors(p, t, sv, yv)
box_equivalent += 1
if sv:
values["log_size_error"].append(size_error)
if yv:
values["yaw_error_rad"].append(yaw_error)
by_order.setdefault(order, []).append(yaw_error)
if include_iou and pv and sv and yv:
values["bev_iou"].append(float(bev_iou(*(torch.tensor(v, dtype=torch.float64) for v in (
p["bottom_center_m"], p["target_size_local_m"], p["yaw_rad"],
t["bottom_center_m"], t["target_size_local_m"], t["yaw_rad"])))))
groups = {}
for i, name in enumerate(batch["exchangeable_group"][0] if hungarian else ()):
if name:
groups.setdefault(name, []).append(i)
matched = [name for name, indices in groups.items() if len(indices) > 1]
incomplete = [name for name in matched if not valid["position"][0, groups[name]].all()]
position_only = [name for name in matched if name not in incomplete and not valid["size"][0, groups[name]].all()]
matching_scope = ("exchangeable_complete_groups_fixed_incomplete_groups"
if hungarian and incomplete and len(incomplete) < len(matched) else
"fixed_incomplete_labels" if hungarian and incomplete else
"exchangeable_groups" if hungarian else "fixed")
return {**{key: _stat(v) for key, v in values.items()},
"yaw_error_rad_by_symmetry_order": {str(order): _stat(v) for order, v in sorted(by_order.items())},
"box_equivalent_objects": box_equivalent, "matching_scope": matching_scope, "incomplete_groups": incomplete, "position_only_groups": position_only}
def _label_rows(sample):
condition = sample["condition"]
origin, scale = room_normalization(condition["room"])
return condition["objects"], _geometry_rows(sample, origin, scale), origin, scale
def _collapse_counts(boxes, categories, room, origin, scale):
"""Pairwise/room statistics of upright boxes given as (bottom_center_m, size, yaw)."""
from fastfill.v2.cell_costs import FLOOR_TOLERANCE_M, blocks_doors, door_zones
polygons = [footprint({"_pos": p, "_size": s, "_yaw": y}) for p, s, y in boxes]
floor = Polygon(room["floor_polygon_xy_m"])
doors = [Polygon(zone).intersection(floor) for zone in door_zones(room, DOOR_ZONE_DEPTH_M)]
floor_z = room.get("floor_z_m") or 0.
counts = {"objects": len(boxes), "pairs": 0, "bev_overlap_pairs": 0, "same_category_pairs": 0,
"stacked_same_category_pairs": 0, "nearest_wall_distance_sum_m": 0., "central_quarter_objects": 0,
"out_of_room_objects": 0, "footprint_outside_objects": 0, "floor_objects": 0, "door_blocking_objects": 0}
for i, (p, size, _) in enumerate(boxes):
counts["nearest_wall_distance_sum_m"] += float(floor.exterior.distance(Point(p[:2])))
counts["out_of_room_objects"] += int(floor.distance(Point(p[:2])) > OUT_OF_ROOM_M) # 0 inside the polygon
area = polygons[i].area
if area > 0:
outside = polygons[i].difference(floor).area
counts["footprint_outside_objects"] += int(outside > FOOTPRINT_VIOLATION_FRACTION * area)
if blocks_doors(abs(p[2] - floor_z) <= FLOOR_TOLERANCE_M, size[2]):
counts["floor_objects"] += 1
blocked = sum(polygons[i].intersection(door).area for door in doors) if area > 0 else 0.
counts["door_blocking_objects"] += int(blocked > FOOTPRINT_VIOLATION_FRACTION * area)
counts["central_quarter_objects"] += all(.25 <= (p[q] - origin[q]) / scale[q] <= .75 for q in (0, 1))
for j in range(i + 1, len(boxes)):
counts["pairs"] += 1
intersection = polygons[i].intersection(polygons[j]).area
union = polygons[i].area + polygons[j].area - intersection
counts["bev_overlap_pairs"] += int(union > 0 and intersection / union > .3)
if categories[i] == categories[j]:
counts["same_category_pairs"] += 1
counts["stacked_same_category_pairs"] += int(math.dist(p[:2], boxes[j][0][:2]) < .1)
return counts
def collapse_metrics(layout, sample, *, hungarian=True):
"""Mode-collapse diagnostics: the prediction (every requested object), the ground truth
(label-complete objects) and predicted_matched (the predicted objects that the reference
matching assigns to the label-complete slots: the ground truth's objects, predicted).
The predicted column never depends on which slots carry labels, so a legal exchange
of IDs leaves it unchanged; predicted_matched follows the legal assignment, so it is
unchanged too except inside groups the matching keeps at fixed identity. Compare
predicted_matched, not predicted, with the ground truth: sources with masked labels
(e.g. size-masked MansionWorld) are in predicted only. Stacking uses horizontal
bottom-centre distance; the central quarter is the middle half of each normalized room
axis; wall distance is to the floor polygon; an object is out of the room when its
bottom centre lies more than ``OUT_OF_ROOM_M`` outside the floor polygon.
"""
objects, rows, origin, scale = _label_rows(sample)
labels = {o["id"]: o for o in sample["target"]["objects"]}
predicted = {o["id"]: o for o in layout["objects"]}
keep = [i for i in range(len(objects)) if all(rows["position_valid"][i]) and all(rows["size_valid"][i])
and math.isfinite(rows["yaw"][i])]
complete = set(keep)
matched = [i for i, j in enumerate(_matched(layout, sample, hungarian)[1][:len(objects)]) if j in complete]
room = sample["condition"]["room"]
columns = {}
for name, source, indices in (("predicted", predicted, range(len(objects))), ("predicted_matched", predicted, matched),
("ground_truth", labels, keep)):
boxes = [(source[objects[i]["id"]]["bottom_center_m"], source[objects[i]["id"]]["target_size_local_m"],
source[objects[i]["id"]]["yaw_rad"]) for i in indices]
columns[name] = _collapse_counts(boxes, [objects[i]["category"] for i in indices], room, origin, scale)
return columns
def fit_baselines(rows):
"""Per-category label pools (normalized position, size) keyed by (row index, object id)."""
pools, keys = {"position": {}, "size": {}}, {}
for index, sample in enumerate(rows):
try:
objects, geometry, _, _ = _label_rows(sample)
except (ValueError, KeyError, TypeError) as exc:
raise ValueError(f"baseline fit row {index} has unusable labels: {exc}") from exc
for i, obj in enumerate(objects):
keys[(index, obj["id"])] = len(keys)
for field in pools:
if all(geometry[f"{field}_valid"][i]):
pools[field].setdefault(obj["category"], []).append((keys[(index, obj["id"])], geometry[field][i]))
def arrays(entries):
return np.array([g for g, _ in entries], dtype=np.int64), np.array([v for _, v in entries], dtype=np.float64).reshape(-1, 3)
fit = {"keys": keys}
for field, pool in pools.items():
fit[field] = {category: arrays(entries) for category, entries in pool.items()}
fit[field + "_all"] = arrays([entry for entries in pool.values() for entry in entries])
return fit
def _estimate(fit, field, category, exclude, reduce):
"""Category statistic without the label keys in ``exclude``; falls back to the all-category pool."""
for fallback, pool in ((False, fit[field].get(category)), (True, fit[field + "_all"])):
if pool is not None:
# ponytail: O(n_category) masked reduction per object; use a sorted-index trick if slow.
vectors = pool[1][~np.isin(pool[0], list(exclude))]
if len(vectors):
return reduce(vectors, axis=0), fallback
return None, True
def baseline_metrics(sample, fit, *, exclude_row=None):
"""Label-only predictors on one request; exclude_row removes every label of that row (row-level leave-one-out).
Same-room duplicates often share one asset's exact size, so excluding only the
object's own label would let its siblings stand in for it.
Room centre predicts the floor-level centre of the room's XY bounds. Uniform yaw is
the expected error of a uniformly random yaw scored like the model's (``_uniform_yaw_error``: pi / (2 * symmetry
order), box-equivalent with the label's own size for swap-allowed objects).
Median size scores swap-allowed objects box-equivalently, like the model.
"""
objects, geometry, origin, scale = _label_rows(sample)
labels = {o["id"]: o for o in sample["target"]["objects"]}
values = {name: [] for name, _ in BASELINE_METRICS}
fallbacks = 0
center = np.array([origin[0] + scale[0] / 2, origin[1] + scale[1] / 2, origin[2]])
own = {fit["keys"][(exclude_row, o["id"])] for o in objects if (exclude_row, o["id"]) in fit["keys"]}
for i, obj in enumerate(objects):
target = labels.get(obj["id"], {})
if all(geometry["position_valid"][i]):
gt = np.asarray(target["bottom_center_m"], dtype=np.float64)
values["room_center_position"].append(float(np.linalg.norm(center - gt)))
estimate, fallback = _estimate(fit, "position", obj["category"], own, np.mean)
if estimate is not None:
fallbacks += fallback
values["category_mean_position"].append(float(np.linalg.norm(np.asarray(origin) + np.asarray(scale) * estimate - gt)))
if all(geometry["size_valid"][i]):
estimate, fallback = _estimate(fit, "size", obj["category"], own, np.median)
if estimate is not None:
fallbacks += fallback
label = target["target_size_local_m"]
error = _log_size_error(estimate, label)
if geometry["swap"][i]:
error = min(error, _log_size_error(estimate, (label[1], label[0], label[2])))
values["category_median_size"].append(error)
if geometry["yaw_valid"][i]:
values["uniform_yaw"].append(_uniform_yaw_error(geometry["symmetry"][i], geometry["swap"][i],
target["target_size_local_m"] if all(geometry["size_valid"][i]) else None))
return {**{name: {metric: _stat(values[name])} for name, metric in BASELINE_METRICS},
"category_fallback_objects": fallbacks}
def evaluate_layout(layout, sample, *, resolver=None, commit_in_memory=False, hungarian=True,
asset_retries=2, repair_calls=0, repair_step_m=.25, max_seconds=10., required_levels=("bbox",)):
sample = migrate_legacy_row(sample)
condition = sample["condition"]
validate_condition(condition)
validate_layout(layout, condition)
target = validate_scene(condition, layout["objects"], required_levels=required_levels)
result = {"model": {"schema_success": True, "requested_ids_exactly_once": True, "positive_valid_size": True,
"target_geometry_valid": target["ok"], "reference": None},
"raw_prediction": layout, "target_validation": target, "collapse": None, "asset": None, "system": None,
"actual_resolved": None, "final_output": None}
try:
result["model"]["reference"] = reference_metrics(layout, sample, hungarian=hungarian)
result["collapse"] = collapse_metrics(layout, sample, hungarian=hungarian)
except (ValueError, KeyError, TypeError, RuntimeError) as exc:
# Reference-label eligibility is independent of model schema and runtime.
result["reference_error"] = {"type": type(exc).__name__, "message": str(exc)}
if resolver is not None:
from fastfill.v2.runtime import AtomicMemoryHost, BoundedTranslationRepair, RuntimeBudget, run_pipeline
host = AtomicMemoryHost() if commit_in_memory else None
runtime = run_pipeline(condition, layout, resolver, host=host,
budget=RuntimeBudget(max_asset_retries=asset_retries, max_repair_calls=repair_calls, max_seconds=max_seconds),
repair=BoundedTranslationRepair(repair_step_m) if repair_calls else None,
required_levels=required_levels,
expected_world_version=0 if host else None,
idempotency_key=str(sample["provenance"].get("scene_id", "evaluation")))
result.update(asset=runtime["metrics"]["asset"], system=runtime["metrics"]["system"], runtime=runtime)
result["actual_resolved"] = runtime.get("actual_first_pass_objects")
result["final_output"] = runtime.get("final_objects")
return result
def _model_summary(outcomes):
n = len(outcomes)
model = {key: sum(bool(o.get("model", {}).get(key)) for o in outcomes) / n for key in
("schema_success", "requested_ids_exactly_once", "positive_valid_size", "target_geometry_valid")}
references = [o["model"]["reference"] for o in outcomes if o.get("model", {}).get("reference")]
reference = {key: {**_pool([r[key] for r in references]),
"scope": "parsed predictions with valid labels; schema failures included in success denominator"}
for key in REFERENCE_METRICS}
reference["box_equivalent_objects"] = sum(r["box_equivalent_objects"] for r in references)
orders = sorted({order for r in references for order in r["yaw_error_rad_by_symmetry_order"]})
reference["yaw_error_rad_by_symmetry_order"] = {
order: _pool([r["yaw_error_rad_by_symmetry_order"][order] for r in references if order in r["yaw_error_rad_by_symmetry_order"]])
for order in orders}
model["reference"] = reference
return model
def _baseline_summary(outcomes, *, paired=False):
"""Label-only baselines over every row, or (``paired``) over the rows the model's reference pool scores: the
requests with a layout, so the same objects as ``model.reference`` (no-layout and over-capacity rows excluded)."""
rows = [o["baselines"] for o in outcomes if o.get("baselines") and (not paired or o.get("model", {}).get("reference"))]
if not rows:
return None
return {**{name: {metric: _pool([r[name][metric] for r in rows])} for name, metric in BASELINE_METRICS},
"category_fallback_objects": sum(r["category_fallback_objects"] for r in rows), "requests": len(rows),
"scope": ("requests with a layout scored against the labels: the model reference's own objects" if paired else
"all evaluation rows with valid labels, independent of model parsing")}
def _room_validation(validation):
"""One room's validator outcome: collision pairs (requested / with fixed objects), any hard violation, any hard unknown."""
checks = validation["checks"]
return {"collision_pairs": sum(c["code"] == "collision" for c in checks),
"fixed_collision_pairs": sum(c["code"] == "fixed_collision" for c in checks),
"hard_violation": any(c["hard"] and c["status"] == "violation" for c in checks),
"hard_unknown": any(c["hard"] and c["status"] == "unknown" for c in checks)}
def _validation_summary(rooms):
"""Collisions and clean rooms over ``_room_validation`` rows. A clean room has no hard violation; unknown checks
are not counted as violations, and the clean rooms with one are counted separately. ``clean_room_rate_known_only``
leaves the rooms with a hard unknown out of the rate (None when every room has one, as in the three-field projection,
whose undeclared supports are all unknown)."""
if not rooms:
return None
clean = [r for r in rooms if not r["hard_violation"]]
known = [r for r in rooms if not r["hard_unknown"]]
return {"rooms": len(rooms), "rooms_with_collision": sum(r["collision_pairs"] + r["fixed_collision_pairs"] > 0 for r in rooms),
"collision_pairs": sum(r["collision_pairs"] for r in rooms),
"fixed_collision_pairs": sum(r["fixed_collision_pairs"] for r in rooms),
"clean_rooms": len(clean), "clean_room_rate": len(clean) / len(rooms),
"clean_rooms_with_hard_unknown": sum(r["hard_unknown"] for r in clean),
"rooms_without_hard_unknown": len(known),
"clean_room_rate_known_only": sum(not r["hard_violation"] for r in known) / len(known) if known else None}
def _collapse_summary(outcomes):
rows = [o["collapse"] for o in outcomes if o.get("collapse")]
if not rows:
return None
def rates(column):
c = {key: sum(r[column][key] for r in rows) for key in rows[0][column]}
ratio = lambda a, b: c[a] / c[b] if c[b] else None
rooms = [r[column]["bev_overlap_pairs"] / r[column]["pairs"] for r in rows if r[column]["pairs"]]
return {"bev_overlap_rate_iou_gt_0.3": ratio("bev_overlap_pairs", "pairs"),
# every room with a pair weighs the same; the pooled rate above is dominated by the largest rooms
"bev_overlap_rate_iou_gt_0.3_room_mean": float(np.mean(rooms)) if rooms else None,
"duplicate_stacking_rate_lt_0.10m": ratio("stacked_same_category_pairs", "same_category_pairs"),
"mean_nearest_wall_distance_m": ratio("nearest_wall_distance_sum_m", "objects"),
"central_quarter_fraction": ratio("central_quarter_objects", "objects"), "collapse_score": collapse_score(c),
"out_of_room_fraction": ratio("out_of_room_objects", "objects"),
"footprint_outside_fraction": ratio("footprint_outside_objects", "objects"),
"door_blocking_fraction_of_floor_objects": ratio("door_blocking_objects", "floor_objects"), # thin ones excluded
"objects": c["objects"], "pairs": c["pairs"], "same_category_pairs": c["same_category_pairs"]}
return {**{column: rates(column) for column in rows[0]}, "requests": len(rows), "scope": COLLAPSE_SCOPE}
def _check_counts(outcomes):
"""Per check code: how many target-validation checks passed, were violated or stayed unknown.
Rows whose own labels exceed the declared height (``provenance.height_conflict``)
count their ceiling checks separately, so a known data conflict is not read as a model violation.
"""
table = {}
for outcome in outcomes:
conflict = bool(outcome.get("provenance", {}).get("height_conflict"))
for check in (outcome.get("target_validation") or {}).get("checks", ()):
code = check["code"] + ("_on_height_conflict_rows" if conflict and check["code"] == "ceiling" else "")
table.setdefault(code, dict.fromkeys(CHECK_STATUSES, 0))[check["status"]] += 1
return dict(sorted(table.items()))
def summarize(outcomes, *, asset_evaluation_requested=False, commit_evaluation_requested=False, baseline_fit_source=None):
n = len(outcomes)
if not n:
raise ValueError("no evaluation requests")
model = _model_summary(outcomes)
baselines, paired = _baseline_summary(outcomes), _baseline_summary(outcomes, paired=True)
for summary in (baselines, paired):
if summary is not None:
summary["fit"] = baseline_fit_source
def cohort(subset):
return {"requests": len(subset), "model": _model_summary(subset), "baselines": _baseline_summary(subset),
"baselines_paired": _baseline_summary(subset, paired=True), "collapse": _collapse_summary(subset),
"validation": {"model": _validation_summary([_room_validation(o["target_validation"]) for o in subset
if o.get("target_validation")])},
"target_validation_checks": _check_counts(subset)}
sources = sorted({str(o.get("provenance", {}).get("source")) for o in outcomes})
by_source = {source: cohort([o for o in outcomes if str(o.get("provenance", {}).get("source")) == source])
for source in sources}
# explicit relations in the request or not: training rows carry extracted constraints in most rooms, so a model
# leaning on them shows up as a gap between the two (outcomes without the count are left out)
by_constraints = {name: cohort(subset) for name, keep in (("with_constraints", lambda c: c > 0),
("without_constraints", lambda c: c == 0))
if (subset := [o for o in outcomes if o.get("constraint_count") is not None
and keep(o["constraint_count"])])}
validated = [o for o in outcomes if o.get("target_validation")]
rooms = [_room_validation(o["target_validation"]) for o in validated]
latency = [o["fastfill_latency_ms"] for o in outcomes if o.get("fastfill_latency_ms") is not None]
latency_by_outcome = {}
for status in ("success", "failure"):
values = [o["fastfill_latency_ms"] for o in outcomes if o.get("fastfill_latency_ms") is not None
and o.get("fastfill_latency_status") == status]
latency_by_outcome[status] = {"requests": len(values), "p50": float(np.percentile(values, 50)) if values else None,
"p95": float(np.percentile(values, 95)) if values else None}
wall = [o["evaluation_wall_time_ms"] for o in outcomes if o.get("evaluation_wall_time_ms") is not None]
asset_evaluated = asset_evaluation_requested or any(o.get("asset") is not None for o in outcomes)
inference_failures = sum("error" in o for o in outcomes)
system_failures = sum(not o.get("runtime", {}).get("ok", False) for o in outcomes) if asset_evaluated else None
strict = sum((not o.get("runtime", {}).get("ok", False))
if asset_evaluated else ("error" in o or not o.get("model", {}).get("target_geometry_valid", False)) for o in outcomes)
report = {"requests": n, "failed_requests": strict, "failed_requests_strict": strict,
"failed_requests_scope": ("strict: no layout, or a hard check not passed, unknown included (with assets: the "
"runtime did not succeed); hard_violation_requests counts only violated hard checks"),
# no layout, or a hard target check violated (unknown checks are not violations)
"hard_violation_requests": sum("error" in o or not o.get("target_validation")
or _room_validation(o["target_validation"])["hard_violation"] for o in outcomes),
"validation": {"model": _validation_summary(rooms),
"ground_truth": _validation_summary([o["ground_truth_validation"] for o in validated
if o.get("ground_truth_validation")]),
"scope": "requests with a validated layout; ground_truth: their labels as written, same validator"},
"inference_failed_requests": inference_failures, "system_failed_requests": system_failures,
# of the inference failures: requests with more objects than the checkpoint's max_objects
"over_capacity_requests": sum(OBJECT_BUDGET_ERROR in o.get("error", {}).get("message", "") for o in outcomes),
"target_validation_checks": _check_counts(outcomes),
"target_validation_not_run": sum(not o.get("target_validation") for o in outcomes),
"stage_denominator": n, "stage_failures": {
"model_schema": sum(not o.get("model", {}).get("schema_success", False) for o in outcomes),
"target_geometry": sum(not o.get("model", {}).get("target_geometry_valid", False) for o in outcomes),
"asset_resolution": sum(not o.get("asset") or o["asset"]["retrieval_coverage"] < 1 for o in outcomes) if asset_evaluated else None,
"actual_first_pass": sum(not o.get("asset", {}).get("first_pass_actual_geometry_validation", False)
if o.get("asset") else True for o in outcomes) if asset_evaluated else None}, "model": model,
"baselines": baselines, "baselines_paired": paired, "collapse": _collapse_summary(outcomes), "by_source": by_source,
"by_constraints": by_constraints,
"latency_ms": {"fastfill_p50": float(np.percentile(latency, 50)) if latency else None,
"fastfill_p95": float(np.percentile(latency, 95)) if latency else None,
"fastfill_observed_requests": len(latency), "fastfill_by_outcome": latency_by_outcome,
"fastfill_scope": "All observed checkpoint generations, including failures; supplied prediction generation time is unknown",
"evaluation_wall_time_p50": float(np.percentile(wall, 50)) if wall else None,
"evaluation_wall_time_p95": float(np.percentile(wall, 95)) if wall else None}}
if asset_evaluated:
report["asset"] = {key: sum(float(o.get("asset", {}).get(key, 0) or 0) for o in outcomes if o.get("asset")) / n
for key in ("retrieval_coverage", "capability_satisfaction", "first_pass_actual_geometry_validation", "resolver_calls")}
differences = [o["asset"]["target_actual_log_size_l1_mean"] for o in outcomes if o.get("asset") and o["asset"]["target_actual_log_size_l1_mean"] is not None]
report["asset"]["target_actual_log_size_l1_mean"] = float(np.mean(differences)) if differences else None
report["system"] = {key: sum(float(o.get("system", {}).get(key, 0) or 0) for o in outcomes if o.get("system")) / n
for key in ("repaired_success", "fallback_rate", "final_commit", "asset_retries", "repair_calls")}
if not commit_evaluation_requested and not any(o.get("system", {}).get("commit_attempted") for o in outcomes if o.get("system")):
report["system"]["final_commit"] = None
runtime_latency = [o["system"]["end_to_end_latency_ms"] for o in outcomes if
o.get("system") and o["system"].get("end_to_end_latency_ms") is not None]
end_to_end = [o["fastfill_latency_ms"]+o["system"]["end_to_end_latency_ms"] for o in outcomes if
o.get("fastfill_latency_ms") is not None and o.get("system") and
o["system"].get("end_to_end_latency_ms") is not None]
report["latency_ms"].update(runtime_p50=float(np.percentile(runtime_latency, 50)) if runtime_latency else None,
runtime_p95=float(np.percentile(runtime_latency, 95)) if runtime_latency else None,
end_to_end_p50=float(np.percentile(end_to_end, 50)) if end_to_end else None,
end_to_end_p95=float(np.percentile(end_to_end, 95)) if end_to_end else None)
report["system"]["solver_success"] = None
return report
def run_evaluation(data, output, *, checkpoint=None, baseline="structured", predictions=None,
catalog=None, device="cpu", max_length=None, max_samples=None,
commit_in_memory=False, hungarian=True, asset_retries=2, repair_calls=0, repair_step_m=.25,
max_seconds=10., max_new_tokens=2048, required_levels=("bbox",), baseline_fit=None,
projections=("full",), grid_decode="spread", save_head_outputs=None, head_outputs=None):
"""Evaluate every request once per projection; ``max_length=None`` binds to the checkpoint.
``save_head_outputs`` (structured checkpoint only): a new directory, apart from ``output``, receiving
``<projection>/row-<row>.npz`` per request (``head_arrays``, the condition, or the online failure) and, at the end,
``manifest.json`` (``HEAD_MANIFEST_KEYS`` of this run). ``head_outputs`` replaces the checkpoint with such a directory:
every request is re-decoded on the CPU (``decode_head_outputs``) and scored exactly as online; its manifest is
reported as ``head_outputs_manifest``."""
validate_required_levels(required_levels)
if sum(source is not None for source in (checkpoint, predictions, head_outputs)) != 1:
raise ValueError("provide exactly one checkpoint, prediction JSONL or head-output directory")
if save_head_outputs is not None and (checkpoint is None or baseline != "structured"):
raise ValueError("head outputs are saved from a structured checkpoint")
projections = tuple(projections)
if not projections or len(set(projections)) != len(projections) or set(projections) - set(PROJECTIONS):
raise ValueError(f"projections must be distinct names from {PROJECTIONS}")
if predictions is not None and projections != ("full",):
raise ValueError("supplied predictions answer the full condition; evaluate other projections from a checkpoint")
if head_outputs is not None:
missing = [p for p in projections if not (Path(head_outputs) / p).is_dir()]
if missing:
raise ValueError(f"{head_outputs} holds no head outputs of projection(s) {missing}")
target = safe_output(output)
if save_head_outputs is not None:
save_head_outputs = safe_output(save_head_outputs)
if save_head_outputs == target or target in save_head_outputs.parents or save_head_outputs in target.parents:
raise ValueError("--save-head-outputs and --output must be separate directories, neither inside the other")
for projection in projections:
(save_head_outputs / projection).mkdir(parents=True, exist_ok=False)
samples = read_samples(data, max_samples=max_samples)
if baseline_fit:
with Path(baseline_fit).open() as stream:
fit = fit_baselines(json.loads(line) for line in stream if line.strip())
fit_source = f"baseline_fit_file:{baseline_fit}"
else:
fit, fit_source = fit_baselines(samples), "evaluation_set_leave_one_out"
model = tokenizer = binding = None
max_length_source = "argument" if max_length is not None else None
if checkpoint:
binding = load_checkpoint_config(checkpoint)
if max_length is None and binding["max_length"] is not None:
max_length, max_length_source = binding["max_length"], binding["source"]
if baseline == "structured":
model = load_model(checkpoint, device=device)
tokenizer_path = Path(checkpoint).parent / "tokenizer"
tokenizer = load_tokenizer("tiny" if model.config.backbone == "tiny" else str(tokenizer_path), local_files_only=True)
else:
from fastfill.v2.text_sft import load_text_model
model, tokenizer = load_text_model(checkpoint, device=device)
if max_length is None:
max_length, max_length_source = DEFAULT_MAX_LENGTH, "default"
supplied = None
if predictions:
with Path(predictions).open() as stream:
supplied = [line.rstrip("\n") for line in stream if line.strip()]
if len(supplied) != len(samples):
raise ValueError("predictions must have one row per selected request, including failures")
resolver = None
if catalog:
from fastfill.v2.serve import load_catalog
resolver = load_catalog(catalog)
if commit_in_memory and resolver is None:
raise ValueError("commit evaluation requires an asset catalog")
torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", "2"))) # CPU inference may use more threads
def evaluate_row(i, sample, projection):
start = time.perf_counter()
raw, fastfill_latency, head = None, None, {} if save_head_outputs is not None else None
try:
if supplied is not None:
raw = supplied[i]
def reject_constant(value):
raise ValueError(f"nonfinite prediction JSON constant {value}")
parsed = json.loads(raw, parse_constant=reject_constant)
layout = parsed.get("layout", parsed) if isinstance(parsed, dict) else parsed
elif head_outputs is not None:
raw = layout = decode_head_outputs(Path(head_outputs) / projection / f"row-{i:06d}.npz", sample["condition"],
grid_decode=grid_decode)
elif baseline == "structured":
raw = layout = predict_layout(model, tokenizer, sample["condition"], max_length=max_length, device=device,
grid_decode=grid_decode, head=head)
else:
from fastfill.v2.text_sft import generate_text
raw = generate_text(model, tokenizer, sample["condition"], max_length=max_length,
max_new_tokens=max_new_tokens, device=device)
layout = json.loads(raw)
fastfill_latency = (time.perf_counter() - start) * 1000 if checkpoint else None
result = evaluate_layout(layout, sample, resolver=resolver, commit_in_memory=commit_in_memory,
hungarian=hungarian, asset_retries=asset_retries, repair_calls=repair_calls,
repair_step_m=repair_step_m, max_seconds=max_seconds, required_levels=required_levels)
except (ValueError, KeyError, TypeError, RuntimeError, StoredFailure) as exc:
if checkpoint and fastfill_latency is None:
fastfill_latency = (time.perf_counter() - start) * 1000
error = exc.error if isinstance(exc, StoredFailure) else {"type": type(exc).__name__, "message": str(exc)}
result = {"error": error, "raw_prediction": raw,
"model": {"schema_success": False, "requested_ids_exactly_once": False, "positive_valid_size": False}}
if head is not None: # a failure before the forward pass is stored as that failure
np.savez_compressed(save_head_outputs / projection / f"row-{i:06d}.npz", condition=np.array(json.dumps(sample["condition"])),
**(head or {"error_type": np.array(result["error"]["type"]),
"error_message": np.array(result["error"]["message"])}))
labelled = migrate_legacy_row(sample)
result.update(row=i, provenance=sample["provenance"], constraint_count=len(sample["condition"].get("constraints", ())),
evaluation_wall_time_ms=(time.perf_counter() - start) * 1000,
fastfill_latency_ms=fastfill_latency,
fastfill_latency_status=("failure" if "error" in result else "success") if checkpoint else "unobserved",
baselines=baseline_metrics(sample, fit, exclude_row=None if baseline_fit else i),
ground_truth_validation=_room_validation(validate_scene( # the labels as written, same validator
labelled["condition"], labelled["target"]["objects"], required_levels=required_levels)))
return result
reports, outcomes = {}, {}
for projection in projections:
rows = list(enumerate(samples)) if projection == "full" else [
(i, projected) for i, projected in enumerate(map(project_minimal, samples)) if projected is not None]
outcomes[projection] = [evaluate_row(i, sample, projection) for i, sample in rows]
reports[projection] = summarize(outcomes[projection], asset_evaluation_requested=resolver is not None,
commit_evaluation_requested=commit_in_memory,
baseline_fit_source=fit_source) if rows else {"requests": 0}
reports[projection].update(projection=projection, skipped_non_rectangular_rooms=len(samples) - len(rows))
report = {**reports[projections[0]], "projections": {name: reports[name] for name in projections[1:]}}
metadata = run_metadata(data)
report.update(baseline=baseline if checkpoint else "supplied_head_outputs" if head_outputs else "supplied_predictions",
head_outputs=str(Path(head_outputs).resolve()) if head_outputs else None,
saved_head_outputs=str(save_head_outputs) if save_head_outputs else None, subset_limit=max_samples,
evaluation_matching="exchangeable_groups" if hungarian else "fixed", commit_scope="in_memory" if commit_in_memory else "not_attempted",
runtime_budget={"asset_retries": asset_retries, "repair_calls": repair_calls,
"repair_step_m": repair_step_m, "max_seconds": max_seconds}, required_levels=list(required_levels),
geometry_level="upright_obb_bev_iou", source_root_modified=False,
checkpoint=str(Path(checkpoint).resolve()) if checkpoint else None, checkpoint_binding=binding,
max_length=max_length if checkpoint else None, max_length_source=max_length_source if checkpoint else None,
grid_decode=grid_decode if checkpoint or head_outputs else None,
predictions_sha256=fingerprint(predictions) if predictions else None,
**{key: metadata[key] for key in ("data_path", "data_sha256", "implementation_sha256", "code_commit", "code_dirty")})
manifest = Path(head_outputs) / "manifest.json" if head_outputs else None # the saving run's checkpoint, data, code
report["head_outputs_manifest"] = json.loads(manifest.read_text()) if manifest and manifest.is_file() else None
if save_head_outputs is not None:
(save_head_outputs / "manifest.json").write_text(json.dumps({key: report[key] for key in HEAD_MANIFEST_KEYS}, indent=2) + "\n")
target.mkdir(parents=True, exist_ok=False)
(target / "report.json").write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
for index, projection in enumerate(projections):
with (target / ("outcomes.jsonl" if not index else f"outcomes-{projection}.jsonl")).open("x") as stream:
for outcome in outcomes[projection]:
stream.write(json.dumps(outcome, allow_nan=False) + "\n")
return report
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--data", type=Path, required=True)
p.add_argument("--output", type=Path, required=True)
p.add_argument("--checkpoint", type=Path)
p.add_argument("--predictions", type=Path)
p.add_argument("--baseline", choices=["structured", "text"], default="structured")
p.add_argument("--catalog", type=Path)
p.add_argument("--device", default="cpu")
p.add_argument("--max-length", type=int, help="default: the checkpoint's training max_length")
p.add_argument("--max-new-tokens", type=int, default=2048)
p.add_argument("--max-samples", type=int)
p.add_argument("--fixed-correspondence", action="store_true")
p.add_argument("--commit-in-memory", action="store_true")
p.add_argument("--asset-retries", type=int, default=2)
p.add_argument("--repair-calls", type=int, default=0)
p.add_argument("--repair-step-m", type=float, default=.25)
p.add_argument("--required-levels", nargs="+", choices=("bbox", "mesh", "physics", "solver"), default=["bbox"])
p.add_argument("--max-seconds", type=float, default=10.)
p.add_argument("--baseline-fit", type=Path, help="JSONL rows for the category baselines; default: evaluation set, leave-one-out")
p.add_argument("--projection", nargs="+", choices=PROJECTIONS, default=["full"], dest="projections",
help="full condition and/or its three-field projection (rectangular rooms); the first is the report's top level")
p.add_argument("--grid-decode", choices=GRID_DECODES, default="spread",
help="grid position head: collision-aware spread (default) or plain per-object argmax")
p.add_argument("--save-head-outputs", type=Path, metavar="DIR",
help="with --checkpoint: new directory for every request's head outputs (<projection>/row-<n>.npz)")
p.add_argument("--from-head-outputs", type=Path, metavar="DIR", dest="head_outputs",
help="instead of --checkpoint: re-decode a --save-head-outputs directory on the CPU and score it")
args = vars(p.parse_args(argv))
args["hungarian"] = not args.pop("fixed_correspondence")
print(json.dumps(run_evaluation(**args), indent=2))
if __name__ == "__main__":
main()
|