mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 21:40:47 +02:00
Extract TwoStageZImagePipeline shared base from QwenZImagePipeline so all three profiles are siblings, not a chain. Add ChromaZImagePipeline on that base with diffusers ChromaImg2ImgPipeline bf16, neutral calibrated prompt, guidance 5.0, four effective steps with Diffusers truncation compensation, no Canny. Flat vendor floors from the 2026-08-29/30 oracle calibration across OpenAI (0.09), Microsoft InvisMark (0.125), Google (0.40), Meta Content Seal (0.17), seed-stability verified on Meta, full-path face-stage interaction clean through the Gemini oracle. Research record in docs/chroma1-engine-research.md with the matched-strength addendum showing Chroma1 regenerates better per unit strength but its boundaries scatter wider than qwen's. Fix text-manifest guard to reject chroma-zimage at the boundary, update all profile references from 'both' to 'all three' across src, docs, README, and python-api.
250 lines
8.7 KiB
Python
Executable File
250 lines
8.7 KiB
Python
Executable File
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["modal>=1.5"]
|
|
# ///
|
|
"""Head-to-head probe: qwen-zimage (the shipped production path) vs Chroma1
|
|
img2img on one fixture -- warm per-image wall time, VRAM peak, and outputs for
|
|
the local fidelity metrics, so quality and price can be compared on the same
|
|
pixels.
|
|
|
|
qwen-zimage runs through the package's own ``QwenZImagePipeline`` (DiffSynth,
|
|
Lightning LoRA, Canny ControlNet, face stage) at its certified OpenAI operating
|
|
point (0.07675) with both stacks resident, exactly as the deployed worker does.
|
|
Chroma1 runs the same neutral prompt with the SDXL-style step compensation
|
|
(``requested_steps`` semantics: always spend 4 effective denoising steps), so a
|
|
low strength is not silently 1 step of 35.
|
|
|
|
Standalone like the other prototypes: not imported by the package, not in
|
|
uv.lock. The package source is mounted at runtime, never installed.
|
|
|
|
Run from the repository root (first run downloads both stacks, ~100 GB total,
|
|
into the shared volume; later runs start warm):
|
|
|
|
uvx modal run scripts/engine_quality_price_probe.py \
|
|
--source data/synthid/originals/'ChatGPT Image May 31, 2026, 02_03_55 PM.png'
|
|
|
|
Oracle the Chroma ladder outputs afterwards (openai.com/verify) and score both
|
|
engines' floor outputs with scripts/fidelity_metrics.py locally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import modal
|
|
|
|
_GPU = "H100"
|
|
# Verified 2026-08-29 from modal.com/pricing.
|
|
_H100_PER_HOUR = 3.95
|
|
_A100_80_PER_HOUR = 2.50
|
|
|
|
app = modal.App("engine-quality-price-probe")
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
image = (
|
|
modal.Image.debian_slim(python_version="3.12")
|
|
.pip_install(
|
|
"torch",
|
|
"torchvision>=0.20.0",
|
|
"diffusers>=0.38,<1",
|
|
"diffsynth>=2.0.17,<3",
|
|
"transformers>=4.53",
|
|
"accelerate",
|
|
"sentencepiece",
|
|
"protobuf",
|
|
"pillow",
|
|
"numpy",
|
|
"opencv-python-headless",
|
|
)
|
|
.env({"HF_HOME": "/cache"})
|
|
.add_local_dir(_ROOT / "src" / "remove_ai_watermarks", remote_path="/pkg/remove_ai_watermarks")
|
|
)
|
|
|
|
# Carries both engines' weights; named for the first prototype that filled it.
|
|
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
|
|
|
|
QWEN_OPENAI_FLOOR = 0.07675 # watermark_profiles.QWEN_ZIMAGE_OPENAI_STRENGTH
|
|
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
|
CHROMA_STRENGTHS = (0.05, 0.06, 0.075, 0.09, 0.10)
|
|
CHROMA_EFFECTIVE_STEPS = 4
|
|
|
|
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
|
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
|
|
|
|
|
def requested_steps(effective_steps: int, strength: float) -> int:
|
|
"""Diffusers truncates the step COUNT; spend effective_steps regardless."""
|
|
import math
|
|
|
|
return max(1, math.ceil(effective_steps / max(float(strength), 1e-6)))
|
|
|
|
|
|
def _psnr(a: Any, b: Any) -> float:
|
|
import numpy as np
|
|
|
|
x = np.asarray(a.convert("RGB"), dtype=np.float64)
|
|
y = np.asarray(b.convert("RGB"), dtype=np.float64)
|
|
mse = float(((x - y) ** 2).mean())
|
|
return 99.0 if mse <= 1e-12 else 10.0 * float(np.log10(255.0**2 / mse))
|
|
|
|
|
|
def _png(image: Any) -> bytes:
|
|
import io
|
|
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
@app.function(image=image, gpu=_GPU, volumes={"/cache": cache}, timeout=3000)
|
|
def probe(source_bytes: bytes, max_side: int) -> dict:
|
|
"""Run both engines; return outputs, timings, and VRAM peaks."""
|
|
import gc
|
|
import io
|
|
import time
|
|
|
|
import torch
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, "/pkg")
|
|
|
|
source = Image.open(io.BytesIO(source_bytes)).convert("RGB")
|
|
if max_side and max(source.size) > max_side:
|
|
scale = max_side / max(source.size)
|
|
source = source.resize(
|
|
(round(source.width * scale), round(source.height * scale)),
|
|
Image.Resampling.LANCZOS,
|
|
)
|
|
|
|
report: dict = {"source_size": source.size, "engines": {}}
|
|
|
|
# --- qwen-zimage, the exact production path ---------------------------------
|
|
print("[qwen] loading the production two-stage pipeline (resident stacks)...", flush=True)
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
|
|
qwen = QwenZImagePipeline(
|
|
device="cuda",
|
|
torch_dtype=torch.bfloat16,
|
|
keep_global_models_on_device=True,
|
|
keep_face_models_on_device=True,
|
|
)
|
|
print(f"[qwen] warmup pass at strength={QWEN_OPENAI_FLOOR}...", flush=True)
|
|
qwen.run(source, strength=QWEN_OPENAI_FLOOR, seed=0)
|
|
|
|
torch.cuda.reset_peak_memory_stats()
|
|
started = time.perf_counter()
|
|
qwen_out = qwen.run(source, strength=QWEN_OPENAI_FLOOR, seed=0)
|
|
qwen_seconds = time.perf_counter() - started
|
|
qwen_vram_gib = torch.cuda.max_memory_allocated() / (1024**3)
|
|
report["engines"]["qwen-zimage"] = {
|
|
"strength": QWEN_OPENAI_FLOOR,
|
|
"warm_seconds": round(qwen_seconds, 2),
|
|
"vram_peak_gib": round(qwen_vram_gib, 1),
|
|
"psnr": round(_psnr(source, qwen_out), 2),
|
|
"png": _png(qwen_out),
|
|
}
|
|
qwen_report = report["engines"]["qwen-zimage"]
|
|
print(
|
|
f"[qwen] warm {qwen_seconds:.2f}s, vram peak {qwen_vram_gib:.1f} GiB, psnr {qwen_report['psnr']:.2f} dB",
|
|
flush=True,
|
|
)
|
|
|
|
del qwen
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
# --- Chroma1, compensated-step ladder ---------------------------------------
|
|
from diffusers import ChromaImg2ImgPipeline
|
|
|
|
print(f"[chroma] loading {CHROMA_MODEL_ID} (bf16)...", flush=True)
|
|
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
|
|
|
width = max(16, (source.width // 16) * 16)
|
|
height = max(16, (source.height // 16) * 16)
|
|
ladder: list[dict] = []
|
|
for strength in CHROMA_STRENGTHS:
|
|
steps = requested_steps(CHROMA_EFFECTIVE_STEPS, strength)
|
|
torch.cuda.reset_peak_memory_stats()
|
|
started = time.perf_counter()
|
|
generator = torch.Generator(device="cpu").manual_seed(0)
|
|
out = chroma(
|
|
prompt=_PROMPT,
|
|
negative_prompt=_NEGATIVE,
|
|
image=source,
|
|
width=width,
|
|
height=height,
|
|
strength=strength,
|
|
num_inference_steps=steps,
|
|
guidance_scale=5.0,
|
|
generator=generator,
|
|
).images[0]
|
|
seconds = time.perf_counter() - started
|
|
vram_gib = torch.cuda.max_memory_allocated() / (1024**3)
|
|
if out.size != source.size:
|
|
out = out.resize(source.size, Image.Resampling.LANCZOS)
|
|
ladder.append(
|
|
{
|
|
"strength": strength,
|
|
"requested_steps": steps,
|
|
"warm_seconds": round(seconds, 2),
|
|
"vram_peak_gib": round(vram_gib, 1),
|
|
"psnr": round(_psnr(source, out), 2),
|
|
"png": _png(out),
|
|
}
|
|
)
|
|
print(
|
|
f"[chroma] s={strength:.3f} steps={steps}/{steps} warm {seconds:.2f}s "
|
|
f"vram {vram_gib:.1f} GiB psnr {ladder[-1]['psnr']:.2f} dB",
|
|
flush=True,
|
|
)
|
|
report["engines"]["chroma1"] = {"ladder": ladder}
|
|
|
|
cache.commit()
|
|
return report
|
|
|
|
|
|
@app.local_entrypoint()
|
|
def main(
|
|
source: str,
|
|
out: str = "out/engine-comparison",
|
|
max_side: int = 1536,
|
|
) -> None:
|
|
source_path = Path(source)
|
|
if not source_path.exists():
|
|
raise SystemExit(f"source not found: {source_path}")
|
|
|
|
report = probe.remote(source_path.read_bytes(), max_side)
|
|
|
|
out_dir = Path(out) / source_path.stem
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
for engine, payload in report["engines"].items():
|
|
if engine == "chroma1":
|
|
for run in payload["ladder"]:
|
|
(out_dir / f"chroma_s{run['strength']:.3f}.png").write_bytes(run["png"])
|
|
else:
|
|
(out_dir / f"{engine}_s{payload['strength']}.png").write_bytes(payload["png"])
|
|
|
|
summary = {
|
|
"source": str(source_path),
|
|
"source_size": report["source_size"],
|
|
"gpu": _GPU,
|
|
"usd_per_hour": {"H100": _H100_PER_HOUR, "A100-80GB": _A100_80_PER_HOUR},
|
|
"engines": {
|
|
engine: (
|
|
{k: v for k, v in payload.items() if k != "png"}
|
|
if engine != "chroma1"
|
|
else {"ladder": [{k: v for k, v in run.items() if k != "png"} for run in payload["ladder"]]}
|
|
)
|
|
for engine, payload in report["engines"].items()
|
|
},
|
|
}
|
|
(out_dir / "timing.json").write_text(json.dumps(summary, indent=2))
|
|
print(json.dumps(summary, indent=2))
|
|
print(f"\noutputs in {out_dir}; oracle the chroma ladder, then score locally with:")
|
|
print(f" uv run scripts/fidelity_metrics.py compare --original '{source}' \\")
|
|
print(f" --variant qwen={out_dir / 'qwen-zimage_s0.0768.png'} --variant chroma={out_dir}/chroma_sXXX.png")
|