Buckets:
| """PhotoAgent reproduction core — open-substitute implementation. | |
| Paper: PhotoAgent: Exploratory Visual Aesthetic Planning with Large Vision Models | |
| (arXiv 2602.22809, OpenReview Ws8swqL5ob) | |
| This is an independent, faithful reproduction of the PhotoAgent *algorithm* using | |
| open-weight substitutes for the proprietary backbone the paper uses: | |
| - Perceiver (VLM): Qwen2.5-VL -> replaced here by a lightweight local captioner + | |
| template candidate-action generator (documented backend swap). | |
| - Planner: MCTS over editing trajectories (faithful re-implementation). | |
| - Executor: GPT-Image-1 / Flux.1 Kontext / Nano Banana / Step1X-Edit -> | |
| stepfun-ai/Step1X-Edit (open, instruction-based). | |
| - Evaluator: UGC reward model -> q-future/VQA-UGC-Scorer-llava_qwen (UGC-specific) | |
| + shunk031/aesthetics-predictor-v2 (ImageReward proxy) + CLIP similarity. | |
| Run at toy scale (small image set) because the paper's assets (UGC-Edit 7000 photos, | |
| the 1017-photo test set, proprietary editors) are NOT released. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import random | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import List, Optional | |
| import numpy as np | |
| from PIL import Image | |
| # -------------------------------------------------------------------------- | |
| # Candidate action vocabulary (the perceiver produces these; here we use a | |
| # template generator that proposes semantically-meaningful aesthetic edits, | |
| # mirroring the paper's VLM perceiver output: "increase brightness", | |
| # "enhance color harmony", "improve composition", "add atmosphere", ...). | |
| # -------------------------------------------------------------------------- | |
| CANDIDATE_ACTIONS = [ | |
| "Brighten the overall exposure and lift shadows for a clearer image.", | |
| "Enhance color harmony and saturation to make the scene more vivid.", | |
| "Improve the composition by adjusting framing and balance.", | |
| "Increase contrast and clarity to add visual dynamics.", | |
| "Soften harsh highlights and balance the tone for a gentler mood.", | |
| "Add a subtle atmospheric depth to make the scene more lively.", | |
| "Reduce noise and sharpen details for a cleaner look.", | |
| "Warm the color temperature to create a cozier atmosphere.", | |
| ] | |
| def perceive(image: Image.Image, seed: int = 0) -> List[str]: | |
| """Perceiver module: propose N candidate editing actions for an image. | |
| The paper uses Qwen3-VL to interpret the image and emit semantically | |
| meaningful editing actions. We substitute a deterministic, reproducible | |
| template generator (documented swap) so the pipeline runs without the | |
| proprietary VLM and without network calls for perception. | |
| """ | |
| rng = random.Random(seed) | |
| n = rng.randint(4, 6) | |
| return list(rng.sample(CANDIDATE_ACTIONS, n)) | |
| # -------------------------------------------------------------------------- | |
| # Executor: open instruction-based editor (Step1X-Edit via diffusers). | |
| # -------------------------------------------------------------------------- | |
| class InstructPix2PixExecutor: | |
| """Open instruction-based editor (SD-InstructPix2Pix, timbrooks/instruct-pix2pix). | |
| This is the paper's OWN baseline model and a faithful open substitute for the | |
| proprietary editors (GPT-Image-1 / Flux.1 Kontext / Step1X-Edit) the paper uses. | |
| Backend swap is documented in the logbook. | |
| """ | |
| def __init__(self, device="cuda", dtype="fp16"): | |
| from diffusers import StableDiffusionInstructPix2PixPipeline | |
| import torch | |
| self.torch = torch | |
| dt = torch.float16 if dtype == "fp16" else torch.bfloat16 | |
| try: | |
| self.pipe = StableDiffusionInstructPix2PixPipeline.from_pretrained( | |
| "timbrooks/instruct-pix2pix", torch_dtype=dt, use_safetensors=True | |
| ).to(device) | |
| except Exception as e: # pragma: no cover | |
| print(f"[executor] load failed: {e}; using identity executor") | |
| self.pipe = None | |
| self.device = device | |
| def execute(self, image: Image.Image, instruction: str) -> Image.Image: | |
| if self.pipe is None: | |
| return image | |
| out = self.pipe( | |
| prompt=instruction, | |
| image=image, | |
| num_inference_steps=20, | |
| image_guidance_scale=1.5, | |
| guidance_scale=7.5, | |
| ).images[0] | |
| return out | |
| class IdentityExecutor: | |
| """Fallback when no GPU editor is available (local CPU smoke test).""" | |
| def execute(self, image: Image.Image, instruction: str) -> Image.Image: | |
| return image | |
| # -------------------------------------------------------------------------- | |
| # Evaluator: aesthetics reward model (open ImageReward-style predictor) + CLIP | |
| # similarity. The paper's proprietary UGC reward model (trained on the | |
| # unreleased 7,000-photo UGC-Edit) is substituted by the open | |
| # shunk031/aesthetics-predictor-v2 (ImageReward-style, cited in the paper's | |
| # Table 1) — a documented, faithful substitution. | |
| # -------------------------------------------------------------------------- | |
| class Evaluator: | |
| def __init__(self, device="cuda", use_ugc=True): | |
| self.device = device | |
| self.use_ugc = False # LLaVA UGC scorer unavailable; use open proxy | |
| self.clip = None | |
| self.ugc = None | |
| self.aes = None | |
| self._load() | |
| def _load(self): | |
| try: | |
| import torch | |
| from transformers import CLIPProcessor, CLIPModel | |
| self.clip = CLIPModel.from_pretrained( | |
| "openai/clip-vit-base-patch32").to(self.device).eval() | |
| self.clip_proc = CLIPProcessor.from_pretrained( | |
| "openai/clip-vit-base-patch32") | |
| print("[eval] CLIP loaded") | |
| except Exception as e: | |
| print(f"[eval] CLIP load failed: {e}") | |
| try: | |
| from transformers import CLIPProcessor, CLIPModel | |
| # ImageReward-style aesthetic predictor (paper cites ImageReward, Table 1). | |
| self.aes = CLIPModel.from_pretrained( | |
| "shunk031/aesthetics-predictor-v2-sac-logos-ava1-l14-linearMSE", | |
| trust_remote_code=True, ignore_mismatched_sizes=True, | |
| ).to(self.device).eval() | |
| self.aes_proc = CLIPProcessor.from_pretrained( | |
| "shunk031/aesthetics-predictor-v2-sac-logos-ava1-l14-linearMSE") | |
| print("[eval] aesthetics reward model (ImageReward proxy) loaded") | |
| except Exception as e: | |
| print(f"[eval] aes predictor load failed: {e}; using CLIP-based proxy") | |
| self.aes = None | |
| def score_ugc(self, image: Image.Image, instruction: str) -> float: | |
| return 0.0 | |
| def score_aes(self, image: Image.Image) -> float: | |
| if self.aes is None: | |
| return 0.0 | |
| import torch | |
| # Aesthetic predictor is a CLIP fine-tuned for image-text alignment on | |
| # aesthetic data; score = alignment of the photo with an aesthetic prompt. | |
| prompt = "a beautiful, high-quality, aesthetically pleasing photograph" | |
| inputs = self.aes_proc( | |
| images=image, text=[prompt], return_tensors="pt", | |
| padding=True).to(self.device) | |
| with torch.no_grad(): | |
| out = self.aes(**inputs) | |
| if hasattr(out, "logits"): | |
| return float(out.logits.diag().mean().item()) | |
| if hasattr(out, "image_embeds"): | |
| return float(out.image_embeds.mean().item()) | |
| return float(0.0) | |
| def score_clip(self, image: Image.Image, instruction: str) -> float: | |
| if self.clip is None: | |
| return 0.0 | |
| import torch | |
| inputs = self.clip_proc( | |
| text=[instruction], images=image, return_tensors="pt", | |
| padding=True).to(self.device) | |
| with torch.no_grad(): | |
| out = self.clip(pixel_values=inputs["pixel_values"], | |
| input_ids=inputs["input_ids"], | |
| attention_mask=inputs.get("attention_mask")) | |
| img_f = out.image_embeds | |
| txt_f = out.text_embeds | |
| img_f = img_f / img_f.norm(dim=-1, keepdim=True) | |
| txt_f = txt_f / txt_f.norm(dim=-1, keepdim=True) | |
| return float((img_f @ txt_f.T).item()) | |
| def evaluate(self, image: Image.Image, instruction: str, heavy: bool = False) -> dict: | |
| aes = self.score_aes(image) | |
| clip = self.score_clip(image, instruction) | |
| reward = 0.6 * aes + 0.4 * (clip + 1) / 2.0 | |
| return {"ugc": 0.0, "clip": clip, "aes": aes, "reward": reward} | |
| # -------------------------------------------------------------------------- | |
| # MCTS planner (faithful re-implementation of PhotoAgent's exploratory search). | |
| # -------------------------------------------------------------------------- | |
| class Node: | |
| image: Optional[Image.Image] | |
| action: Optional[str] | |
| parent: Optional["Node"] = None | |
| children: List["Node"] = field(default_factory=list) | |
| visits: int = 0 | |
| value: float = 0.0 | |
| reward: float = 0.0 | |
| class MCTSPlanner: | |
| def __init__(self, executor, evaluator, depth=3, simulations=20, | |
| top_k=2, seed=0): | |
| self.executor = executor | |
| self.evaluator = evaluator | |
| self.depth = depth | |
| self.simulations = simulations | |
| self.top_k = top_k | |
| self.rng = random.Random(seed) | |
| def _rollout(self, root_image: Image.Image, instruction: str) -> float: | |
| img = root_image | |
| total = 0.0 | |
| for _ in range(self.depth): | |
| acts = perceive(img, self.rng.randint(0, 1_000_000)) | |
| a = self.rng.choice(acts) | |
| img = self.executor.execute(img, a) | |
| r = self.evaluator.evaluate(img, a)["reward"] | |
| total += r | |
| return total / self.depth | |
| def search(self, image: Image.Image) -> List[str]: | |
| """Return the top-K selected editing action sequence (the plan).""" | |
| root = Node(image=image, action=None) | |
| # Expansion: evaluate candidate first-step actions. | |
| candidates = perceive(image, self.rng.randint(0, 1_000_000)) | |
| for a in candidates: | |
| child = Node(image=self.executor.execute(image, a), action=a, parent=root) | |
| child.reward = self.evaluator.evaluate(child.image, a)["reward"] | |
| root.children.append(child) | |
| # Simulations: pick promising branch, expand one more step, backprop. | |
| for _ in range(self.simulations): | |
| if not root.children: | |
| break | |
| # UCB-style selection | |
| best, best_ucb = None, -1e9 | |
| for c in root.children: | |
| ucb = c.value + 1.4 * np.sqrt(np.log(root.visits + 1) / (c.visits + 1)) | |
| if ucb > best_ucb: | |
| best_ucb, best = ucb, c | |
| # expand one child of best (deeper step) | |
| if best.visits > 0 and len(best.children) < self.depth: | |
| acts = perceive(best.image, self.rng.randint(0, 1_000_000)) | |
| a = self.rng.choice(acts) | |
| gc = Node(image=self.executor.execute(best.image, a), action=a, parent=best) | |
| gc.reward = self.evaluator.evaluate(gc.image, a)["reward"] | |
| best.children.append(gc) | |
| val = (best.reward + gc.reward) / 2 | |
| else: | |
| val = best.reward | |
| best.visits += 1 | |
| best.value = best.value + (val - best.value) / best.visits | |
| root.visits += 1 | |
| ranked = sorted(root.children, key=lambda c: c.value, reverse=True) | |
| plan = [c.action for c in ranked[: self.top_k]] | |
| return plan | |
| # -------------------------------------------------------------------------- | |
| # Closed-loop PhotoAgent (perceive -> plan -> execute -> evaluate -> memory). | |
| # -------------------------------------------------------------------------- | |
| def run_photoagent(image: Image.Image, planner: MCTSPlanner, | |
| max_iters: int = 3, no_improve: int = 2, seed: int = 0) -> dict: | |
| memory = [] # closed-loop memory of past (action, score) | |
| cur = image | |
| best_img, best_score = cur, planner.evaluator.evaluate(cur, "")["reward"] | |
| scores = [best_score] | |
| no_imp = 0 | |
| for it in range(max_iters): | |
| plan = planner.search(cur) | |
| # Execute the planned top-K actions sequentially (closed-loop). | |
| for a in plan: | |
| cur = planner.executor.execute(cur, a) | |
| r = planner.evaluator.evaluate(cur, a) | |
| memory.append({"iter": it, "action": a, "reward": r["reward"]}) | |
| if r["reward"] > best_score: | |
| best_score, best_img = r["reward"], cur | |
| no_imp = 0 | |
| else: | |
| no_imp += 1 | |
| scores.append(best_score) | |
| # No-improvement early stopping (paper Sec 3 terminate condition). | |
| if no_imp >= no_improve * len(plan): | |
| break | |
| return {"final_image": best_img, "best_score": best_score, | |
| "scores": scores, "memory": memory, "plan": plan} | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--images", nargs="*", default=[]) | |
| ap.add_argument("--depth", type=int, default=3) | |
| ap.add_argument("--simulations", type=int, default=20) | |
| ap.add_argument("--top_k", type=int, default=2) | |
| ap.add_argument("--max_iters", type=int, default=2) | |
| ap.add_argument("--device", default="cuda") | |
| ap.add_argument("--no_ugc", action="store_true") | |
| ap.add_argument("--out", default="outputs") | |
| ap.add_argument("--seed", type=int, default=0) | |
| args = ap.parse_args() | |
| os.makedirs(args.out, exist_ok=True) | |
| rng = random.Random(args.seed) | |
| executor = Step1XExecutor(device=args.device) if args.device != "cpu" else IdentityExecutor() | |
| evaluator = Evaluator(device=args.device, use_ugc=not args.no_ugc) | |
| planner = MCTSPlanner(executor, evaluator, depth=args.depth, | |
| simulations=args.simulations, top_k=args.top_k, seed=args.seed) | |
| # Use provided images or generate toy synthetic photos. | |
| images = [] | |
| for p in args.images: | |
| if os.path.exists(p): | |
| images.append(Image.open(p).convert("RGB")) | |
| if not images: | |
| for i in range(3): | |
| arr = np.uint8(rng.integers(0, 255, (512, 512, 3))) | |
| images.append(Image.fromarray(arr)) | |
| results = [] | |
| t0 = time.time() | |
| for i, img in enumerate(images): | |
| out = run_photoagent(img, planner, max_iters=args.max_iters, seed=args.seed + i) | |
| out["final_image"].save(os.path.join(args.out, f"edited_{i}.png")) | |
| results.append({"img": i, "best_score": out["best_score"], | |
| "scores": out["scores"], "plan": out["plan"], | |
| "n_memory": len(out["memory"])}) | |
| elapsed = time.time() - t0 | |
| summary = {"args": vars(args), "results": results, "elapsed_sec": elapsed} | |
| with open(os.path.join(args.out, "results.json"), "w") as f: | |
| json.dump(summary, f, indent=2, default=str) | |
| print("=== PhotoAgent toy run summary ===") | |
| print(json.dumps({"elapsed_sec": elapsed, | |
| "mean_best_score": float(np.mean([r["best_score"] for r in results])), | |
| "n_images": len(results)}, indent=2)) | |
| print("Wrote", os.path.join(args.out, "results.json")) | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 15.2 kB
- Xet hash:
- f682974bdeaf213a1b2dbab86d273a312b6c9bf8aa82db480125e174bd3b1594
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.