Yashp2003's picture
download
raw
15.2 kB
"""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).
# --------------------------------------------------------------------------
@dataclass
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.