refactor(tools): group the sixty scripts by what they talk to
One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
1 parent
f681d83736
commit
4d27109668
168 files changed
+799
-407
No files matched your search
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure how far an object sits above its own shadow.
|
||||
|
||||
python3 Tools/review/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png \
|
||||
--object-box 0.52,0.55,0.60,0.75 --out /tmp/gigline/shadow-gap.json
|
||||
|
||||
Why a measurement instead of another opinion: a still cannot distinguish "the object floats"
|
||||
from "the object stands on a raised kerb and its shadow falls past it onto the road", and a
|
||||
vision model asked to arbitrate gives a different answer each run. The vertical pixel gap
|
||||
between the lowest object pixel and the top of its shadow is objective.
|
||||
|
||||
Method, deliberately simple and stated so it can be argued with:
|
||||
|
||||
1. Crop the region of interest (the object box, fractions of the frame).
|
||||
2. The object is the set of pixels that differ from the local background - the median colour of
|
||||
the crop's border.
|
||||
3. The shadow is the darkest connected region *below* the object.
|
||||
4. Report the gap in pixels between the object's lowest row and the shadow's top row, and the
|
||||
object's height in pixels, so the gap can be read as a fraction of the object.
|
||||
|
||||
A large gap relative to the object's height is the floating signal; a small gap is contact.
|
||||
The thresholds are arguments, not constants, because the right values depend on the camera.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
DEFAULT_GAP_FRACTION = 0.12 # gap > 12 % of the object's pixel height reads as floating
|
||||
SHADOW_DELTA = 38 # how much darker than the background a pixel must be to be shadow
|
||||
|
||||
|
||||
def border_median(image: Image.Image):
|
||||
"""Median colour of the crop's border, used as the local background."""
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
samples = []
|
||||
step = max(1, width // 60)
|
||||
for x in range(0, width, step):
|
||||
samples.append(pixels[x, 1])
|
||||
samples.append(pixels[x, height - 2])
|
||||
for y in range(0, height, max(1, height // 60)):
|
||||
samples.append(pixels[1, y])
|
||||
samples.append(pixels[width - 2, y])
|
||||
if not samples:
|
||||
return (128, 128, 128)
|
||||
channels = list(zip(*samples))
|
||||
return tuple(sorted(channel)[len(channel) // 2] for channel in channels)
|
||||
|
||||
|
||||
def classify_pixels(image: Image.Image, background, object_delta=18):
|
||||
"""Return two masks: the object (differs from background) and the shadow (darker)."""
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
object_rows = [0] * height
|
||||
shadow_rows = [0] * height
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b = pixels[x, y]
|
||||
delta = max(abs(r - background[0]), abs(g - background[1]), abs(b - background[2]))
|
||||
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
background_luminance = (0.2126 * background[0] + 0.7152 * background[1]
|
||||
+ 0.0722 * background[2])
|
||||
# A shadow is a darkened version of the surface; an object is much darker or a
|
||||
# different colour. Classifying by absolute delta alone put every shadow in the
|
||||
# object mask, which is why the first runs reported "no shadow found".
|
||||
ratio = luminance / max(1.0, background_luminance)
|
||||
# A shadow darkens every channel by the same proportion; an object does not.
|
||||
# Comparing the per-channel scale factors is what separates them - an absolute
|
||||
# delta cannot, because a shadow's delta is as large as a dark object's.
|
||||
scales = [rgb / max(1.0, base) for rgb, base in zip((r, g, b), background)]
|
||||
spread = max(scales) - min(scales)
|
||||
if 0.45 <= ratio < 0.90 and spread < 0.15:
|
||||
shadow_rows[y] += 1
|
||||
elif ratio < 0.45 or delta > object_delta:
|
||||
object_rows[y] += 1
|
||||
return object_rows, shadow_rows
|
||||
|
||||
|
||||
def measure(image_path: str, box, gap_fraction=DEFAULT_GAP_FRACTION) -> dict:
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
width, height = image.size
|
||||
x0, y0, x1, y1 = (int(box[0] * width), int(box[1] * height),
|
||||
int(box[2] * width), int(box[3] * height))
|
||||
crop = image.crop((x0, y0, x1, y1))
|
||||
background = border_median(crop)
|
||||
object_rows, shadow_rows = classify_pixels(crop, background)
|
||||
|
||||
object_rows_nonzero = [y for y, count in enumerate(object_rows) if count > 4]
|
||||
if not object_rows_nonzero:
|
||||
return {"error": "no object found in the box", "background": background}
|
||||
object_top, object_bottom = object_rows_nonzero[0], object_rows_nonzero[-1]
|
||||
object_height = max(1, object_bottom - object_top)
|
||||
|
||||
# Shadow rows below the object's lowest row.
|
||||
shadow_below = [y for y, count in enumerate(shadow_rows)
|
||||
if y > object_bottom and count > 4]
|
||||
shadow_top = shadow_below[0] if shadow_below else None
|
||||
gap = None if shadow_top is None else shadow_top - object_bottom
|
||||
|
||||
verdict = "unknown"
|
||||
if gap is not None:
|
||||
verdict = "floating" if gap > object_height * gap_fraction else "contact"
|
||||
return {
|
||||
"image": os.path.abspath(image_path),
|
||||
"box_pixels": [x0, y0, x1, y1],
|
||||
"background": list(background),
|
||||
"object_top_row": object_top,
|
||||
"object_bottom_row": object_bottom,
|
||||
"object_height_px": object_height,
|
||||
"shadow_top_row": shadow_top,
|
||||
"gap_px": gap,
|
||||
"gap_fraction_of_object": None if gap is None else round(gap / object_height, 3),
|
||||
"threshold_fraction": gap_fraction,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--image", required=True)
|
||||
ap.add_argument("--object-box", required=True,
|
||||
help="x0,y0,x1,y1 as fractions of the frame around the object and its shadow")
|
||||
ap.add_argument("--gap-fraction", type=float, default=DEFAULT_GAP_FRACTION,
|
||||
help="gap/height above this reads as floating (default 0.12)")
|
||||
ap.add_argument("--out", default="/tmp/gigline/shadow-gap.json")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
box = [float(v) for v in args.object_box.split(",")]
|
||||
if len(box) != 4:
|
||||
print("error: --object-box needs four comma-separated fractions", file=sys.stderr)
|
||||
return 2
|
||||
if not os.path.exists(args.image):
|
||||
print(f"error: no such image: {args.image}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = measure(args.image, box, args.gap_fraction)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
|
||||
with open(args.out, "w") as handle:
|
||||
json.dump(result, handle, indent=2)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("verdict") != "unknown" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user