diff options
Diffstat (limited to 'worldalign/synth_world.py')
| -rw-r--r-- | worldalign/synth_world.py | 506 |
1 files changed, 506 insertions, 0 deletions
diff --git a/worldalign/synth_world.py b/worldalign/synth_world.py new file mode 100644 index 0000000..8875fde --- /dev/null +++ b/worldalign/synth_world.py @@ -0,0 +1,506 @@ +"""Synthetic closed world v0: procedural scenes with exact closure. + +A scene's discrete state is a set of object groups (count, size, color, +shape) plus a set of spatial relation constraints between groups. Captions +mention exactly the discrete state; renders realize it with continuous +nuisance (positions, jitter) resampled per view. Shared content and +modality-private variation are therefore separated by construction, and +every dial -- vocabulary, ontology size, groups per scene, scene count, +orbit multiplicity, intervention density -- is a generator argument. + +Outputs mirror the Flickr pipeline layout: a manifest with disjoint +vision-only and text-only scene rows plus held-out val/test, an image +directory with `sceneNNNNNN_vK.png` views, and per-scene caption lists. +Intervention variants with edit metadata are stored for the response +battery; nothing downstream reads them yet. +""" + +from __future__ import annotations + +import argparse +import json +import math +import random +from pathlib import Path + +from PIL import Image, ImageDraw + +from .common import write_json + +COLORS = { + "red": (205, 49, 49), + "orange": (224, 133, 44), + "yellow": (229, 213, 74), + "green": (64, 168, 75), + "blue": (59, 104, 214), + "purple": (139, 72, 190), + "pink": (228, 136, 179), + "white": (238, 238, 238), + "gray": (140, 140, 140), + "brown": (125, 84, 48), +} +SHAPES = ("circle", "square", "triangle", "star", "diamond", "cross") +SIZES = {"small": (7, 10), "medium": (13, 17), "large": (21, 26)} +COUNT_WORDS = {1: "one", 2: "two", 3: "three", 4: "four"} +RELATIONS = ("left of", "right of", "above", "below") +BACKGROUND = (24, 24, 28) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="artifacts/synth_v0") + parser.add_argument("--image-size", type=int, default=128) + parser.add_argument("--vision-only", type=int, default=12000) + parser.add_argument("--text-only", type=int, default=12000) + parser.add_argument("--val", type=int, default=1000) + parser.add_argument("--test", type=int, default=1000) + parser.add_argument("--min-groups", type=int, default=2) + parser.add_argument("--max-groups", type=int, default=4) + parser.add_argument("--visual-views", type=int, default=4) + parser.add_argument("--captions", type=int, default=4) + parser.add_argument("--interventions", type=int, default=2, + help="Edited variants per eval scene.") + parser.add_argument("--seed", type=int, default=20260730) + parser.add_argument( + "--skew", + type=float, + default=0.0, + help="Zipf exponent for colour and shape sampling. Non-uniform " + "marginals make the cross-modal value correspondence recoverable " + "from frequency alone, with no declared dictionary.", + ) + parser.add_argument( + "--correlate", + type=float, + default=0.0, + help="Colour-shape coupling strength. Real worlds pair values " + "(bananas are yellow), which makes co-occurrence structure " + "informative where bare marginals are not.", + ) + parser.add_argument( + "--report-bias", + type=float, + default=0.0, + help="Reporting bias strength. Captions mention rarer colours " + "preferentially, so text frequency stops tracking pixel " + "frequency -- the situation in real corpora, where nobody " + "describes the grey road.", + ) + parser.add_argument( + "--texture", + action="store_true", + help="Dot-textured object fills: within-object structure that " + "gives binding objectives a purchase, without changing the " + "discrete state or the captions.", + ) + return parser.parse_args() + + +_PROFILE_CACHE: dict[tuple[str, float], list[float]] = {} + + +def colour_shape_profile(colour: str, concentration: float) -> list[float]: + """Fixed per-colour shape distribution, deterministic in the colour.""" + key = (colour, concentration) + if key not in _PROFILE_CACHE: + local = random.Random(hash(key) & 0xFFFFFFFF) + raw = [local.gammavariate(1.0 / concentration, 1.0) for _ in SHAPES] + total = sum(raw) or 1.0 + _PROFILE_CACHE[key] = [value / total for value in raw] + return _PROFILE_CACHE[key] + + +def zipf_weights(count: int, exponent: float) -> list[float]: + raw = [1.0 / (rank + 1) ** exponent for rank in range(count)] + total = sum(raw) + return [value / total for value in raw] + + +def sample_group( + rng: random.Random, skew: float = 0.0, correlate: float = 0.0 +) -> dict: + if skew > 0.0: + colors = rng.choices( + list(COLORS), weights=zipf_weights(len(COLORS), skew) + )[0] + else: + colors = rng.choice(list(COLORS)) + if correlate > 0.0: + # Each colour carries its own shape distribution, drawn once from a + # Dirichlet with concentration 1/correlate. Distinct profiles make + # the joint table identifying, as in a real world where objects of + # a kind take characteristic forms. + weights = colour_shape_profile(colors, correlate) + if skew > 0.0: + base = zipf_weights(len(SHAPES), skew) + weights = [w * b for w, b in zip(weights, base)] + shapes = rng.choices(SHAPES, weights=weights)[0] + elif skew > 0.0: + shapes = rng.choices( + SHAPES, weights=zipf_weights(len(SHAPES), skew) + )[0] + else: + shapes = rng.choice(SHAPES) + return { + "count": rng.randint(1, 4), + "size": rng.choice(list(SIZES)), + "color": colors, + "shape": shapes, + } + + +def sample_scene(rng: random.Random, args: argparse.Namespace) -> dict: + skew = getattr(args, "skew", 0.0) + correlate = getattr(args, "correlate", 0.0) + groups = [ + sample_group(rng, skew, correlate) + for _ in range(rng.randint(args.min_groups, args.max_groups)) + ] + # Reject referential ambiguity: color-shape pairs are unique per scene, + # so every relational mention has exactly one referent. + signatures = [(group["color"], group["shape"]) for group in groups] + while len(set(signatures)) < len(signatures): + groups = [sample_group(rng, skew, correlate) for _ in range(len(groups))] + signatures = [(group["color"], group["shape"]) for group in groups] + relation_count = rng.randint(1, min(3, len(groups) * (len(groups) - 1) // 2)) + pairs = [(a, b) for a in range(len(groups)) for b in range(len(groups)) if a < b] + rng.shuffle(pairs) + relations = [ + {"a": a, "b": b, "relation": rng.choice(RELATIONS)} + for a, b in pairs[:relation_count] + ] + return {"groups": groups, "relations": relations} + + +def relation_holds(relation: str, pa: tuple[float, float], pb: tuple[float, float], margin: float) -> bool: + if relation == "left of": + return pa[0] < pb[0] - margin + if relation == "right of": + return pa[0] > pb[0] + margin + if relation == "above": + return pa[1] < pb[1] - margin + return pa[1] > pb[1] + margin + + +def group_geometry(group: dict, rng: random.Random, size: int) -> dict: + low, high = SIZES[group["size"]] + shrink = (1.0, 0.95, 0.85, 0.75)[group["count"] - 1] + radius = rng.uniform(low, high) * size / 128.0 * shrink + count = group["count"] + if count == 1: + offsets = [(0.0, 0.0)] + extent = radius + else: + # Ring placement guarantees exact visible multiplicity: adjacent + # spacing 2 s sin(pi/k) stays above 2.15 r by construction. + ring = 1.10 * 1.075 * radius / math.sin(math.pi / count) + phase = rng.uniform(0.0, 2.0 * math.pi) + offsets = [ + ( + ring * math.cos(phase + 2.0 * math.pi * k / count), + ring * math.sin(phase + 2.0 * math.pi * k / count), + ) + for k in range(count) + ] + extent = ring + radius + return {"radius": radius, "offsets": offsets, "extent": extent} + + +def worst_case_extent(group: dict, size: int) -> float: + high = SIZES[group["size"]][1] * size / 128.0 + shrink = (1.0, 0.95, 0.85, 0.75)[group["count"] - 1] + radius = high * shrink + if group["count"] == 1: + return radius + return 1.10 * 1.075 * radius / math.sin(math.pi / group["count"]) + radius + + +def place_groups( + scene: dict, + rng: random.Random, + size: int, + extents: list[float] | None = None, +) -> list[tuple[float, float]] | None: + margin = size * 0.08 + if extents is None: + extents = [worst_case_extent(group, size) for group in scene["groups"]] + for _ in range(300): + centers = [] + feasible = True + for extent in extents: + low, high = extent + 2.0, size - extent - 2.0 + if low >= high: + feasible = False + break + centers.append((rng.uniform(low, high), rng.uniform(low, high))) + if not feasible: + return None + if any( + math.dist(centers[a], centers[b]) < extents[a] + extents[b] + 4.0 + for a in range(len(centers)) + for b in range(a + 1, len(centers)) + ): + continue + if all( + relation_holds(r["relation"], centers[r["a"]], centers[r["b"]], margin) + for r in scene["relations"] + ): + return centers + return None + + +def draw_shape(draw: ImageDraw.ImageDraw, shape: str, x: float, y: float, radius: float, fill: tuple) -> None: + if shape == "circle": + draw.ellipse([x - radius, y - radius, x + radius, y + radius], fill=fill) + elif shape == "square": + draw.rectangle([x - radius, y - radius, x + radius, y + radius], fill=fill) + elif shape == "triangle": + draw.polygon( + [(x, y - radius), (x - radius, y + radius), (x + radius, y + radius)], + fill=fill, + ) + elif shape == "diamond": + draw.polygon( + [(x, y - radius), (x + radius, y), (x, y + radius), (x - radius, y)], + fill=fill, + ) + elif shape == "cross": + arm = radius * 0.42 + draw.rectangle([x - arm, y - radius, x + arm, y + radius], fill=fill) + draw.rectangle([x - radius, y - arm, x + radius, y + arm], fill=fill) + else: # star + points = [] + for k in range(10): + r = radius if k % 2 == 0 else radius * 0.45 + angle = -math.pi / 2 + k * math.pi / 5 + points.append((x + r * math.cos(angle), y + r * math.sin(angle))) + draw.polygon(points, fill=fill) + + +def render_scene(scene: dict, rng: random.Random, size: int, texture: bool = False) -> Image.Image | None: + geometries = [group_geometry(group, rng, size) for group in scene["groups"]] + centers = place_groups( + scene, rng, size, extents=[g["extent"] for g in geometries] + ) + if centers is None: + return None + shade = rng.randint(-6, 6) + image = Image.new("RGB", (size, size), tuple(c + shade for c in BACKGROUND)) + draw = ImageDraw.Draw(image) + for group, geometry, center in zip(scene["groups"], geometries, centers): + base = tuple( + min(255, max(0, channel + rng.randint(-10, 10))) + for channel in COLORS[group["color"]] + ) + for off in geometry["offsets"]: + draw_shape( + draw, + group["shape"], + center[0] + off[0], + center[1] + off[1], + geometry["radius"], + base, + ) + return image + + +def group_phrase(group: dict, rng: random.Random) -> str: + size_word = "" if group["size"] == "medium" else group["size"] + " " + plural = "es" if group["shape"] == "cross" else "s" + noun = group["shape"] + (plural if group["count"] > 1 else "") + count_word = COUNT_WORDS[group["count"]] if group["count"] > 1 else ( + "a" if size_word == "" or size_word[0] not in "aeiou" else "an" + ) + return f"{count_word} {size_word}{group['color']} {noun}" + + +def caption_scene( + scene: dict, rng: random.Random, report_bias: float = 0.0 +) -> str: + order = list(range(len(scene["groups"]))) + rng.shuffle(order) + if report_bias > 0.0 and len(order) > 1: + # Mention probability falls with the colour's population frequency, + # so the text marginal inverts the pixel marginal. + ranks = {name: index for index, name in enumerate(COLORS)} + keep = [] + for index in order: + rank = ranks[scene["groups"][index]["color"]] + salience = ((rank + 1) / len(COLORS)) ** report_bias + if rng.random() < 0.25 + 0.75 * salience: + keep.append(index) + order = keep or order[:1] + phrases = [group_phrase(scene["groups"][index], rng) for index in order] + if len(phrases) > 1: + listed = ", ".join(phrases[:-1]) + " and " + phrases[-1] + else: + listed = phrases[0] + opener = rng.choice(["there are", "the picture shows", "you can see"]) + sentences = [f"{opener} {listed}."] + relations = list(scene["relations"]) + rng.shuffle(relations) + mentioned = set(order) + relations = [ + relation + for relation in relations + if relation["a"] in mentioned and relation["b"] in mentioned + ] + for relation in relations: + subject = group_phrase(scene["groups"][relation["a"]], rng) + target = group_phrase(scene["groups"][relation["b"]], rng) + sentences.append(f"the {subject.split(' ', 1)[1]} {'are' if scene['groups'][relation['a']]['count'] > 1 else 'is'} {relation['relation']} the {target.split(' ', 1)[1]}.") + return " ".join(sentences) + + +def intervene(scene: dict, rng: random.Random) -> tuple[dict, dict]: + edited = json.loads(json.dumps(scene)) + kinds = ["recolor", "count", "remove", "relation"] + if len(edited["groups"]) <= 2: + kinds.remove("remove") + if not edited["relations"]: + kinds.remove("relation") + kind = rng.choice(kinds) + if kind == "recolor": + index = rng.randrange(len(edited["groups"])) + old = edited["groups"][index]["color"] + shape = edited["groups"][index]["shape"] + taken = { + g["color"] + for i, g in enumerate(edited["groups"]) + if i != index and g["shape"] == shape + } + edited["groups"][index]["color"] = rng.choice( + [c for c in COLORS if c != old and c not in taken] + ) + detail = {"kind": kind, "group": index, "from": old, "to": edited["groups"][index]["color"]} + elif kind == "count": + index = rng.randrange(len(edited["groups"])) + old = edited["groups"][index]["count"] + edited["groups"][index]["count"] = old % 4 + 1 + detail = {"kind": kind, "group": index, "from": old, "to": edited["groups"][index]["count"]} + elif kind == "remove": + index = rng.randrange(len(edited["groups"])) + edited["groups"].pop(index) + edited["relations"] = [ + r for r in edited["relations"] if r["a"] != index and r["b"] != index + ] + for relation in edited["relations"]: + relation["a"] -= relation["a"] > index + relation["b"] -= relation["b"] > index + detail = {"kind": kind, "group": index} + else: + index = rng.randrange(len(edited["relations"])) + old = edited["relations"][index]["relation"] + flip = {"left of": "right of", "right of": "left of", "above": "below", "below": "above"} + edited["relations"][index]["relation"] = flip[old] + detail = {"kind": kind, "relation_index": index, "from": old, "to": flip[old]} + return edited, detail + + +def main() -> None: + args = parse_args() + rng = random.Random(args.seed) + output = Path(args.output_dir) + (output / "images").mkdir(parents=True, exist_ok=True) + + total = args.vision_only + args.text_only + args.val + args.test + scenes, captions = [], [] + while len(scenes) < total: + scene = sample_scene(rng, args) + if place_groups(scene, rng, args.image_size) is None: + continue + scenes.append(scene) + captions.append( + [ + caption_scene(scene, rng, args.report_bias) + for _ in range(args.captions) + ] + ) + + rows = list(range(total)) + vision_only = rows[: args.vision_only] + text_only = rows[args.vision_only : args.vision_only + args.text_only] + val = rows[args.vision_only + args.text_only : args.vision_only + args.text_only + args.val] + test = rows[-args.test :] + + needs_render = set(vision_only) | set(val) | set(test) + rendered = 0 + for row in sorted(needs_render): + for view in range(args.visual_views): + image = None + while image is None: + image = render_scene( + scenes[row], rng, args.image_size, texture=args.texture + ) + image.save(output / "images" / f"scene{row:06d}_v{view}.png") + rendered += 1 + + interventions = [] + for row in val + test: + for _ in range(args.interventions): + edited, detail = intervene(scenes[row], rng) + if place_groups(edited, rng, args.image_size) is None: + continue + index = len(interventions) + image = None + while image is None: + image = render_scene( + edited, rng, args.image_size, texture=args.texture + ) + image.save(output / "images" / f"edit{index:06d}.png") + interventions.append( + { + "index": index, + "row": row, + "detail": detail, + "caption": caption_scene(edited, rng, args.report_bias), + } + ) + + vocabulary = sorted( + { + token + for caption_list in captions + for caption in caption_list + for token in caption.replace(",", " ").replace(".", " ").split() + } + ) + manifest = { + "dataset": "synth_v0", + "image_dir": str(output / "images"), + "image_size": args.image_size, + "seed": args.seed, + "visual_views": args.visual_views, + "vision_only_train": vision_only, + "text_only_train": text_only, + "paired_train": [], + "val": val, + "test": test, + "all_rows": total, + "vocabulary_size": len(vocabulary), + "vocabulary": vocabulary, + "protocol": ( + "vision_only_train and text_only_train are disjoint scene rows; " + "val/test pairs are held out for evaluation only. Captions " + "mention exactly the discrete scene state." + ), + } + write_json(output / "manifest.json", manifest) + write_json(output / "scenes.private.json", {"scenes": scenes}) + write_json(output / "captions.json", {"captions": captions}) + write_json(output / "interventions.private.json", {"interventions": interventions}) + print( + json.dumps( + { + "scenes": total, + "rendered_images": rendered, + "interventions": len(interventions), + "vocabulary_size": len(vocabulary), + "example_caption": captions[test[0]][0], + } + ) + ) + + +if __name__ == "__main__": + main() |
