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.
153 lines
6.7 KiB
Python
153 lines
6.7 KiB
Python
#!/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())
|