Files
gig-line/Tools/review/shadow_gap.py
T
zwitschi 4d27109668 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.
2026-11-10 12:40:30 +01:00

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())