summaryrefslogtreecommitdiff
path: root/worldalign/tier0_pipeline.py
diff options
context:
space:
mode:
Diffstat (limited to 'worldalign/tier0_pipeline.py')
-rw-r--r--worldalign/tier0_pipeline.py310
1 files changed, 310 insertions, 0 deletions
diff --git a/worldalign/tier0_pipeline.py b/worldalign/tier0_pipeline.py
new file mode 100644
index 0000000..778e20f
--- /dev/null
+++ b/worldalign/tier0_pipeline.py
@@ -0,0 +1,310 @@
+"""Tier 0 pipeline: unpaired corpora to aligned relation fields.
+
+Consolidates the components that produced the synthetic world's
+end-to-end result, each of which was developed and measured separately:
+
+- watershed object extraction, which splits touching ring members that
+ connected components merge (exact object count 74.2% to 100%);
+- Gestalt appearance grouping, which assembles members into groups by
+ shared colour, size, and shape rather than by a distance threshold
+ (exact group count 71.9% to 90.2%);
+- size classes from radial extent under one-dimensional k-means per
+ member count, because area confounds size with shape and the classes
+ are gap-separated rather than equally populated (52.7% to 87.9%);
+- text factor families from mutual exclusivity within a group phrase;
+- the cross-modal value correspondence from marginal frequency rank.
+
+Nothing crosses modalities except the frequency ranking, and the two
+corpora it reads are disjoint: no instance appears on both sides.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from collections import Counter, defaultdict
+from pathlib import Path
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+from scipy import ndimage
+from scipy.cluster.hierarchy import fcluster, linkage
+from sklearn.cluster import KMeans
+from tqdm import tqdm
+
+from .common import read_json, seed_everything, write_json
+from .synth_cc_battery import moment_field
+from .synth_set_battery import parse_group_phrases
+from .synth_towers import load_image
+from .tier0_dictionary import partition_text_vocabulary, text_token_statistics
+
+NUMBER_TO_COUNT = {"two": 2, "three": 3, "four": 4}
+SINGULAR = ("a", "an")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--data-dir", default="artifacts/synth_v1")
+ parser.add_argument("--split", choices=["val", "test"], default="test")
+ parser.add_argument("--samples", type=int, default=256)
+ parser.add_argument("--offset", type=int, default=0)
+ parser.add_argument("--fit-scenes", type=int, default=1500)
+ parser.add_argument("--peak-distance", type=int, default=5)
+ parser.add_argument("--group-threshold", type=float, default=0.35)
+ parser.add_argument("--views", type=int, default=0, help="0 uses manifest.")
+ parser.add_argument("--seed", type=int, default=0)
+ parser.add_argument("--output", required=True)
+ parser.add_argument("--states-output", default="")
+ return parser.parse_args()
+
+
+def extract_objects(image: torch.Tensor, peak_distance: int) -> list[dict]:
+ """Foreground objects, splitting touching ones by distance watershed."""
+ array = image.permute(1, 2, 0).numpy()
+ background = np.median(array.reshape(-1, 3), axis=0)
+ foreground = np.abs(array - background).sum(-1) > 0.12
+ distance = ndimage.distance_transform_edt(foreground)
+ window = 2 * peak_distance + 1
+ peaks = (
+ distance >= ndimage.maximum_filter(distance, size=window) - 1e-9
+ ) & (distance > 1.0)
+ labels, count = ndimage.label(peaks)
+ if count == 0:
+ labels, count = ndimage.label(foreground)
+ else:
+ while True:
+ grown = ndimage.grey_dilation(labels, size=3)
+ take = (labels == 0) & foreground & (grown > 0)
+ if not take.any():
+ break
+ labels = np.where(take, grown, labels)
+ count = labels.max()
+ objects = []
+ for index in range(1, count + 1):
+ mask = labels == index
+ area = float(mask.sum())
+ if area < 6:
+ continue
+ ys, xs = np.nonzero(mask)
+ centred_y, centred_x = ys - ys.mean(), xs - xs.mean()
+ covariance = np.cov(np.stack([centred_x, centred_y])) + 1e-6 * np.eye(2)
+ eigenvalues = np.linalg.eigvalsh(covariance)
+ eroded = ndimage.binary_erosion(mask)
+ perimeter = max(float((mask & ~eroded).sum()), 1.0)
+ box = (xs.max() - xs.min() + 1) * (ys.max() - ys.min() + 1)
+ objects.append(
+ {
+ "rgb": array[mask].mean(0),
+ "area": area,
+ "extent": float(np.hypot(centred_y, centred_x).max()),
+ "shape": np.array(
+ [
+ 4 * np.pi * area / perimeter**2,
+ 1 - eigenvalues[0] / eigenvalues[1],
+ area / max(box, 1),
+ ]
+ ),
+ }
+ )
+ return objects
+
+
+def appearance(item: dict) -> np.ndarray:
+ return np.concatenate(
+ [item["rgb"] * 3.0, [np.log(item["area"] + 1e-6) * 0.6], item["shape"]]
+ )
+
+
+def group_objects(objects: list[dict], threshold: float) -> list[dict]:
+ """Members of one group share appearance; group by similarity."""
+ if not objects:
+ return []
+ if len(objects) == 1:
+ labels = np.array([0])
+ else:
+ features = np.stack([appearance(item) for item in objects])
+ labels = fcluster(linkage(features, "complete"), threshold, "distance")
+ buckets: dict[int, list[dict]] = {}
+ for item, label in zip(objects, labels):
+ buckets.setdefault(int(label), []).append(item)
+ return [
+ {
+ "rgb": np.mean([item["rgb"] for item in members], axis=0),
+ "members": len(members),
+ "extent": float(np.mean([item["extent"] for item in members])),
+ }
+ for members in buckets.values()
+ ]
+
+
+class VisionCoder:
+ """Colour classes and size classes fitted on the vision corpus alone."""
+
+ def __init__(self, groups: list[dict], classes: int, seed: int) -> None:
+ colours = np.stack([group["rgb"] for group in groups]).astype(np.float64)
+ self.colour_model = KMeans(classes, n_init=10, random_state=seed).fit(colours)
+ frequency = Counter(self.colour_model.labels_.tolist())
+ self.colour_rank = {
+ label: rank for rank, (label, _) in enumerate(frequency.most_common())
+ }
+ by_count: dict[int, list[float]] = defaultdict(list)
+ for group in groups:
+ by_count[min(group["members"], 4)].append(group["extent"])
+ self.size_models = {}
+ for count, extents in by_count.items():
+ model = KMeans(3, n_init=10, random_state=seed).fit(
+ np.asarray(extents, dtype=np.float64)[:, None]
+ )
+ order = np.argsort(model.cluster_centers_[:, 0])
+ self.size_models[count] = (
+ model,
+ {int(label): rank for rank, label in enumerate(order)},
+ )
+
+ def encode(self, groups: list[dict], classes: int) -> torch.Tensor:
+ if not groups:
+ groups = [{"rgb": np.zeros(3), "members": 1, "extent": 1.0}]
+ colours = self.colour_model.predict(
+ np.stack([group["rgb"] for group in groups]).astype(np.float64)
+ )
+ vectors = []
+ for group, colour in zip(groups, colours):
+ model, order = self.size_models[min(group["members"], 4)]
+ size = order[
+ int(model.predict(np.array([[group["extent"]]], dtype=np.float64))[0])
+ ]
+ vectors.append(
+ factor_vector(self.colour_rank[int(colour)], group["members"], size, classes)
+ )
+ return F.normalize(torch.stack(vectors), dim=-1)
+
+
+def factor_vector(colour: int, count: int, size: int, classes: int) -> torch.Tensor:
+ vector = torch.zeros(classes + 4 + 3)
+ vector[colour] = 1.0
+ vector[classes + min(count - 1, 3)] = 1.0
+ vector[classes + 4 + size] = 1.0
+ return vector
+
+
+def encode_caption(caption: str, colour_rank: dict[str, int], classes: int) -> torch.Tensor:
+ vectors = []
+ for phrase in parse_group_phrases(caption):
+ tokens = phrase.split()
+ colour = next((colour_rank[t] for t in tokens if t in colour_rank), 0)
+ count = (
+ 1
+ if any(token in SINGULAR for token in tokens)
+ else next(
+ (NUMBER_TO_COUNT[t] for t in tokens if t in NUMBER_TO_COUNT), 1
+ )
+ )
+ size = 0 if "small" in tokens else (2 if "large" in tokens else 1)
+ vectors.append(factor_vector(colour, count, size, classes))
+ if not vectors:
+ vectors = [factor_vector(0, 1, 1, classes)]
+ return F.normalize(torch.stack(vectors), dim=-1)
+
+
+def moment_state(states: torch.Tensor) -> torch.Tensor:
+ first = states.mean(0)
+ second = (states[:, :, None] * states[:, None, :]).mean(0).flatten()
+ return torch.cat([first, second])
+
+
+def main() -> None:
+ args = parse_args()
+ seed_everything(args.seed)
+ manifest = read_json(Path(args.data_dir, "manifest.json"))
+ captions = read_json(Path(args.data_dir, "captions.json"))["captions"]
+ image_dir = Path(manifest["image_dir"])
+ views = args.views or manifest["visual_views"]
+ rows = manifest[args.split][args.offset : args.offset + args.samples]
+
+ families = partition_text_vocabulary(
+ text_token_statistics(captions, manifest["text_only_train"])
+ )
+ colour_words = families["colour_words"]
+ classes = len(colour_words)
+ colour_rank = {word: rank for rank, word in enumerate(colour_words)}
+
+ fit_groups = [
+ group
+ for row in tqdm(
+ manifest["vision_only_train"][: args.fit_scenes], desc="fit vision"
+ )
+ for group in group_objects(
+ extract_objects(
+ load_image(image_dir / f"scene{row:06d}_v0.png"), args.peak_distance
+ ),
+ args.group_threshold,
+ )
+ ]
+ coder = VisionCoder(fit_groups, classes, args.seed)
+
+ per_view_fields = []
+ view_states = []
+ for view in range(views):
+ sets = [
+ coder.encode(
+ group_objects(
+ extract_objects(
+ load_image(image_dir / f"scene{row:06d}_v{view}.png"),
+ args.peak_distance,
+ ),
+ args.group_threshold,
+ ),
+ classes,
+ )
+ for row in tqdm(rows, desc=f"encode view {view}")
+ ]
+ per_view_fields.append(moment_field(sets))
+ if view == 0:
+ view_states = [moment_state(item) for item in sets]
+ visual_field = torch.stack(per_view_fields).mean(0)
+
+ text_sets = [encode_caption(captions[row][0], colour_rank, classes) for row in rows]
+ text_field = moment_field(text_sets)
+
+ mask = ~np.eye(len(rows), dtype=bool)
+ correlation = float(
+ np.corrcoef(
+ visual_field.double().numpy()[mask], text_field.double().numpy()[mask]
+ )[0, 1]
+ )
+ torch.save(
+ {"visual_field": visual_field, "text_field": text_field, "rows": rows},
+ args.output,
+ )
+ if args.states_output:
+ torch.save(
+ {
+ "vision_states": torch.stack(view_states),
+ "text_states": torch.stack([moment_state(item) for item in text_sets]),
+ "rows": rows,
+ },
+ args.states_output,
+ )
+ summary = {
+ "data_dir": args.data_dir,
+ "split": args.split,
+ "samples": len(rows),
+ "colour_classes": classes,
+ "text_families": {
+ key: families[key] for key in ("count_words", "colour_words", "size_words")
+ },
+ "field_correlation_at_truth": correlation,
+ "note": (
+ "The dictionary is derived from disjoint corpora; the "
+ "correlation is a diagnostic computed with hidden pairs and "
+ "never used by the pipeline."
+ ),
+ }
+ print(json.dumps({"field_correlation_at_truth": correlation}))
+ write_json(args.output.replace(".pt", ".json"), summary)
+ print(f"Wrote {args.output}")
+
+
+if __name__ == "__main__":
+ main()