Files
AnyImage/tools/models/cli.py
nan 489003c2cc Add new tests and functionality for image processing and geometry handling
- Introduced color_image.py for preparing standalone color images for tests.
- Added hdr_image.py to create HDR textures for testing.
- Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities.
- Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group.
- Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs.
- Added material.py to inherit material slots onto generated geometry.
- Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
2026-09-10 14:42:17 +08:00

265 lines
8.0 KiB
Python

"""Prepare AnyImage ONNX artifacts and upload them to OmooLab R2."""
import argparse
import hashlib
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import Request, urlopen
PROJECT_ROOT = Path(__file__).resolve().parents[2]
MODELS_DIR = PROJECT_ROOT / "models"
MODEL_CACHE_DIR = PROJECT_ROOT / ".model-cache"
SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage"
sys.path.insert(0, str(SOURCE_ROOT))
from server.model_catalog import DOWNLOADABLE_MODELS
R2_REMOTE = "omoolab-r2:ai-models"
CHUNK_SIZE = 1024 * 1024
@dataclass(frozen=True)
class BuildSpec:
source_url: str
source_filename: str
source_size: int
source_sha256: str
exporter: str
@dataclass(frozen=True)
class ModelFile:
key: str
folder: str
filename: str
size: int
sha256: str
source_url: str = ""
build: BuildSpec | None = None
@property
def destination(self):
return MODELS_DIR / self.folder / self.filename
@property
def remote(self):
return f"{R2_REMOTE}/{self.folder}/{self.filename}"
MODEL_BUILDS = {
"REALESRGAN_GENERAL_WDN_X4V3": BuildSpec(
source_url=(
"https://github.com/xinntao/Real-ESRGAN/releases/download/"
"v0.2.5.0/realesr-general-wdn-x4v3.pth"
),
source_filename="realesr-general-wdn-x4v3.pth",
source_size=4885111,
source_sha256=(
"1641f8c4464b9f097c9fdda5589273713f67cf59f3d909e0bd688f0cee269dca"
),
exporter="realesrgan_wdn_x4",
),
"SAFMN_REAL_X4": BuildSpec(
source_url=(
"https://github.com/sunny2109/SAFMN/releases/download/"
"v0.1.0/SAFMN_L_Real_LSDIR_x4-v2.pth"
),
source_filename="SAFMN_L_Real_LSDIR_x4-v2.pth",
source_size=45002097,
source_sha256=(
"f1ac0ee3ee143fbbc49aff6584cb7b48b71ba8d1961dfb8ad076b98ca4799ae2"
),
exporter="safmn_real_x4",
),
}
def hf_url(repository, filename):
return f"https://huggingface.co/{repository}/resolve/main/{filename}"
def catalog_model_files():
files = []
for key, model in DOWNLOADABLE_MODELS.items():
for filename, size, sha256 in model.r2_files:
source_url = ""
if model.huggingface_repository:
source_url = hf_url(model.huggingface_repository, filename)
files.append(
ModelFile(
key=key,
folder=model.r2_directory,
filename=filename,
size=size,
sha256=sha256,
source_url=source_url,
build=MODEL_BUILDS.get(key),
)
)
return tuple(files)
MODEL_FILES = catalog_model_files()
def file_sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def verify_file(path, size, sha256):
path = Path(path)
if not path.is_file():
return False
if path.stat().st_size != size:
raise RuntimeError(f"File has an unexpected size: {path}")
if file_sha256(path) != sha256:
raise RuntimeError(f"File has an unexpected SHA-256: {path}")
return True
def download_file(url, destination, size, sha256):
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f"{destination.name}.part")
temporary.unlink(missing_ok=True)
request = Request(url, headers={"User-Agent": "AnyImage model build"})
print(f"Download: {url}")
downloaded = 0
digest = hashlib.sha256()
try:
with urlopen(request, timeout=60) as response, temporary.open("wb") as output:
while chunk := response.read(CHUNK_SIZE):
output.write(chunk)
digest.update(chunk)
downloaded += len(chunk)
print(
f"\r {downloaded / 1024**2:.1f} / {size / 1024**2:.1f} MiB",
end="",
flush=True,
)
print()
if downloaded != size:
raise RuntimeError(f"size mismatch: {downloaded} != {size}")
if digest.hexdigest() != sha256:
raise RuntimeError("SHA-256 mismatch")
temporary.replace(destination)
except Exception:
temporary.unlink(missing_ok=True)
raise
def prepare_source(build):
source = MODEL_CACHE_DIR / build.source_filename
if verify_file(source, build.source_size, build.source_sha256):
return source
download_file(
build.source_url,
source,
build.source_size,
build.source_sha256,
)
return source
def build_model(model):
from tools.models.exports import export_model
source = prepare_source(model.build)
destination = model.destination
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f"{destination.name}.part")
temporary.unlink(missing_ok=True)
print(f"Build: {destination.relative_to(PROJECT_ROOT)}")
try:
export_model(model.build.exporter, source, temporary)
verify_file(temporary, model.size, model.sha256)
temporary.replace(destination)
except Exception:
temporary.unlink(missing_ok=True)
raise
def prepare_model(model):
if verify_file(model.destination, model.size, model.sha256):
print(f"Skip existing: {model.destination.relative_to(PROJECT_ROOT)}")
return
if model.build:
build_model(model)
return
if model.key in {"PISA_SR", "MOGE3_VITL"}:
model.destination.parent.mkdir(parents=True, exist_ok=True)
exporter = "pisa_sr_export.py" if model.key == "PISA_SR" else "moge3_export.py"
with TemporaryDirectory(prefix=f".{model.folder}-", dir=MODELS_DIR) as directory:
subprocess.run(
["uv", "run", "--script", str(Path(__file__).with_name(exporter)),
"--destination", directory],
cwd=PROJECT_ROOT, check=True,
)
models = [item for item in MODEL_FILES if item.key == model.key]
for item in models:
if not verify_file(Path(directory) / item.filename, item.size, item.sha256):
raise RuntimeError(f"{model.key} export differs from the published catalog")
for path in Path(directory).iterdir():
path.replace(model.destination.parent / path.name)
return
if not model.source_url:
raise RuntimeError(f"No upstream source or builder for {model.key}")
download_file(
model.source_url,
model.destination,
model.size,
model.sha256,
)
def upload_models(models=MODEL_FILES):
executable = shutil.which("rclone")
if executable is None:
raise RuntimeError("rclone is not available on PATH")
for model in models:
print(f"Upload: {model.remote}")
subprocess.run(
[
executable,
"copyto",
str(model.destination),
model.remote,
"--progress",
],
cwd=PROJECT_ROOT,
check=True,
)
def parse_args():
parser = argparse.ArgumentParser(description="Prepare models locally or sync them to R2.")
commands = parser.add_subparsers(dest="command")
commands.add_parser("prepare", help="Download or export models and verify them locally")
commands.add_parser("sync", help="Prepare and verify all models, then upload them to R2")
args = parser.parse_args()
if args.command is None:
parser.print_help()
return args
def main():
args = parse_args()
if args.command is None:
return
for model in MODEL_FILES:
prepare_model(model)
if args.command == "sync":
upload_models()
if __name__ == "__main__":
main()