forma3d-tiny-3 / recipe /pipeline.py
Lucasgitguy-co's picture
Publish evaluated Forma3D Tiny 3 ABO continuation
3469f52 verified
Raw History Blame Contribute Delete
29 kB
"""ABO mesh-proxy continuation of Forma3D Tiny 2. No large teacher models run."""
import argparse
import fcntl
import getpass
import hashlib
import importlib.util
import importlib.metadata
import json
import math
from pathlib import Path
import random
import re
import shutil
import subprocess
import sys
import time
BASE = 'Lucasgitguy-co/forma3d-tiny-2'
BASE_REV = '1648582b07a0f7f9d482a8c0f4cf89e6ecd86972'
DATA = 'codepawl/abo-glb'
DATA_REV = 'c7c8293fed3b7ab122ef5d7896521a187e0962d7'
DEST = 'Lucasgitguy-co/forma3d-tiny-3'
HERE = Path(__file__).resolve().parent
def dump(path, value):
path = Path(path)
tmp = path.with_suffix(path.suffix + '.tmp')
tmp.write_text(json.dumps(value, indent=2, allow_nan=False) + '\n')
tmp.replace(path)
def sha(path):
h = hashlib.sha256()
with Path(path).open('rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def read(path):
return json.loads(Path(path).read_text())
def log(**kw):
print(json.dumps(kw, allow_nan=False), flush=True)
def authenticate():
from huggingface_hub import HfApi, get_token, login
token = get_token()
if not token:
print('Enter a Hugging Face WRITE token for Lucasgitguy-co. Input is hidden.\n'
'Create one at https://huggingface.co/settings/tokens', flush=True)
token = getpass.getpass('Hugging Face token: ')
login(token=token, add_to_git_credential=False)
api = HfApi(token=token)
identity = api.whoami()
owner = DEST.split('/')[0]
valid_owner = identity['name'].lower() == owner.lower()
valid_org = any(o['name'].lower() == owner.lower() and o.get('roleInOrg') in ('admin', 'write')
for o in identity.get('orgs', []))
if not (valid_owner or valid_org):
raise RuntimeError(f'This login cannot publish under {owner}. Use a token for that account.')
if identity.get('auth', {}).get('accessToken', {}).get('role') == 'read':
raise RuntimeError('This is a read-only token. Use a write token.')
print('Hugging Face login verified. No repository is created until evaluation passes.', flush=True)
return api
def upstream(root):
from huggingface_hub import snapshot_download
source = root / 'upstream'
snapshot_download(BASE, revision=BASE_REV, local_dir=source,
allow_patterns=['src/*', 'scripts/prepare_hf.py', 'model.pt', 'config.json',
'pyproject.toml', 'LICENSE', 'NOTICE', 'THIRD_PARTY.md',
'licenses/*', 'ATTRIBUTIONS.json', 'README.md'], max_workers=2)
sys.path.insert(0, str(source / 'src'))
spec = importlib.util.spec_from_file_location('base_prepare', source / 'scripts/prepare_hf.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return source, mod
def prepare(root, source, helper, args):
import numpy as np
from huggingface_hub import hf_hub_download
from image3d_student.data import cache_sample, CachedDataset
from image3d_student.splats import Splats, preview
from image3d_student.engine import split_rows
folder = root / 'data'
folder.mkdir(exist_ok=True)
ready = folder / 'preparation.json'
if ready.exists():
for split in ('train', 'validation', 'test'):
CachedDataset(folder / f'{split}.jsonl')
print('Reusing verified prepared data.', flush=True)
return folder
metadata = hf_hub_download(DATA, 'metadata.jsonl', repo_type='dataset', revision=DATA_REV,
local_dir=root / 'metadata')
license_path = hf_hub_download(DATA, 'LICENSE-CC-BY-4.0.txt', repo_type='dataset',
revision=DATA_REV, local_dir=root / 'metadata')
shutil.copy2(license_path, folder / 'LICENSE-ABO-CC-BY-4.0.txt')
records = [json.loads(line) for line in Path(metadata).read_text().splitlines() if line.strip()]
candidates = [r for r in records if 0 < r.get('triangles', 0) <= 200_000
and 0 < r.get('pack_bytes', 0) <= 8 * 1024**2
and re.fullmatch(r'[A-Za-z0-9_-]+', r['id'])]
candidates.sort(key=lambda r: r['id'])
random.Random(args.seed).shuffle(candidates)
progress = folder / 'progress.json'
state = read(progress) if progress.exists() else {'rows': [], 'sources': [], 'skipped': []}
rows, sources = state['rows'], state['sources']
completed = {s['asset_id'] for s in sources}
prior = read(source / 'ATTRIBUTIONS.json')
blocked_hashes = set()
def visit(x):
if isinstance(x, dict):
for k, v in x.items():
if k == 'sha256' and isinstance(v, str):
blocked_hashes.add(v)
visit(v)
elif isinstance(x, list):
for v in x:
visit(v)
visit(prior)
seen_hashes = {s['sha256'] for s in sources} | blocked_hashes
fetch = helper.Downloader(root / 'downloads', 8, 3072, 4)
for record in candidates:
if len(sources) >= args.objects:
break
uid = record['id']
if uid in completed:
continue
stage = folder / '_staging'
if stage.exists():
shutil.rmtree(stage)
stage.mkdir()
try:
filename = f'objects/{uid}/{uid}.glb'
raw = fetch.get(DATA, DATA_REV, filename)
digest = sha(raw)
if digest in seen_hashes:
continue
decoded = stage / 'decoded.glb'
subprocess.run(['node', str(HERE / 'decode.mjs'), str(raw), str(decoded)],
check=True, capture_output=True, text=True, timeout=120)
sample_seed = int(digest[:8], 16) ^ args.seed
splats, normalization = helper.mesh_splats(decoded, 4096, 16, sample_seed)
info = {'repo': DATA, 'revision': DATA_REV, 'path': filename, 'sha256': digest,
'decoded_sha256': sha(decoded), 'asset_id': uid, 'object_id': 'abo-' + uid,
'amazon_item_ids': record.get('amazon_item_ids', []),
'title': record.get('name', uid), 'product_type': record.get('product_type'),
'author': 'Amazon.com; Amazon Berkeley Objects contributors',
'conversion': 'CodePawl GLB conversion; decompressed, normalized and sampled for training',
'license': 'cc-by-4.0', 'license_url': 'https://creativecommons.org/licenses/by/4.0/',
'source_url': f'https://huggingface.co/datasets/{DATA}/blob/{DATA_REV}/{filename}'}
new_rows = []
for view in range(4):
yaw = 2 * np.pi * view / 4
c, s = np.cos(yaw), np.sin(yaw)
rotation = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], np.float32)
rotated = Splats(splats.xyz @ rotation.T, splats.rgb, splats.scale,
splats.quat, splats.opacity)
picture = preview(rotated, size=128)
if picture.getbbox() is None:
raise ValueError('Invisible proxy render')
picture.save(stage / 'input.png')
new_rows.append(cache_sample(stage, f'abo-{uid}-v{view}', stage / 'input.png',
rotated, 16, source='mesh-proxy:' + DATA,
extra={**info, 'normalization_matrix': normalization, 'yaw_radians': float(yaw),
'render': 'orthographic Gaussian proxy on black; camera +z',
'target': 'mesh-derived isotropic surface samples; no pretrained teacher'}))
for name in ('images', 'targets'):
(folder / name).mkdir(exist_ok=True)
for p in (stage / name).iterdir():
p.replace(folder / name / p.name)
rows.extend(new_rows)
sources.append(info)
seen_hashes.add(digest)
completed.add(uid)
dump(progress, state)
log(prepared_objects=len(sources), requested=args.objects, asset=uid)
except RuntimeError:
raise
except Exception as exc:
error = str(exc)[:300]
if isinstance(exc, subprocess.CalledProcessError):
error = exc.stderr[-500:]
state['skipped'].append({'id': uid, 'error': error})
dump(progress, state)
log(skipped=uid, error=error)
if len(state['skipped']) >= 20 and not sources:
raise RuntimeError('Twenty objects failed. Inspect data/progress.json before continuing.') from exc
finally:
shutil.rmtree(stage, ignore_errors=True)
if len(sources) < args.objects:
raise RuntimeError(f'Only {len(sources)} usable objects; need {args.objects}. Progress is saved.')
# Connect duplicate objects, shared Amazon item IDs and exact rendered data before splitting.
parent = {s['object_id']: s['object_id'] for s in sources}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
a, b = find(a), find(b)
parent[max(a, b)] = min(a, b)
identities = {}
for info in sources:
for key in [('mesh', info['sha256'])] + [('amazon', a) for a in info['amazon_item_ids']]:
if key in identities:
union(info['object_id'], identities[key])
identities[key] = info['object_id']
for row in rows:
for field in ('image_sha256', 'target_sha256'):
key = (field, row[field])
if key in identities:
union(row['metadata']['object_id'], identities[key])
identities[key] = row['metadata']['object_id']
for row in rows:
row['metadata']['object_id'] = find(row['metadata']['object_id'])
for info in sources:
info['object_id'] = find(info['object_id'])
development, test = split_rows(rows, .15, args.seed + 1)
training, validation = split_rows(development, .20, args.seed + 2)
splits = {'train': training, 'validation': validation, 'test': test}
for name, items in splits.items():
(folder / f'{name}.jsonl').write_text(''.join(json.dumps(r) + '\n' for r in items))
CachedDataset(folder / f'{name}.jsonl')
dump(folder / 'sources.json', sources)
dump(ready, {'dataset': DATA, 'revision': DATA_REV, 'objects': len(sources), 'views': len(rows),
'groups': {k: len({r['metadata']['object_id'] for r in v}) for k, v in splits.items()},
'split_views': {k: len(v) for k, v in splits.items()},
'manifest_sha256': {k: sha(folder / f'{k}.jsonl') for k in splits},
'grouping': 'object IDs, shared Amazon item IDs, exact mesh/image/target hashes',
'limitations': 'Near duplicates can remain. Cross-dataset overlap with ancestor training '
'is not fully known; only recorded exact source hashes were excluded.'})
return folder
def cpu_tree(value):
import torch
if isinstance(value, torch.Tensor):
return value.detach().cpu()
if isinstance(value, dict):
return {k: cpu_tree(v) for k, v in value.items()}
if isinstance(value, list):
return [cpu_tree(v) for v in value]
if isinstance(value, tuple):
return tuple(cpu_tree(v) for v in value)
return value
def train(root, source, folder, args):
import torch
from torch.utils.data import DataLoader, default_collate
from image3d_student.data import CachedDataset
from image3d_student.model import Student, distillation_loss
from image3d_student.engine import device_for, evaluate, on_device
device = device_for(args.device)
torch.manual_seed(args.seed)
run = root / 'training'
run.mkdir(exist_ok=True)
train_data = CachedDataset(folder / 'train.jsonl')
val_data = CachedDataset(folder / 'validation.jsonl')
val_loader = DataLoader(val_data, batch_size=1, num_workers=0)
base = torch.load(source / 'model.pt', map_location='cpu', weights_only=True)
if base['config'] != {'grid': 16, 'width': 64} or base['format'] != 1:
raise RuntimeError('Unexpected base model architecture.')
model = Student(**base['config']).to(device)
model.load_state_dict(base['model'], strict=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
rng = random.Random(args.seed)
meta = {'base_model': BASE, 'base_revision': BASE_REV, 'base_sha256': sha(source / 'model.pt'),
'parameters': sum(p.numel() for p in model.parameters()), 'device': str(device),
'torch': str(torch.__version__), 'dataset': DATA, 'dataset_revision': DATA_REV,
'steps_requested': args.steps, 'batch_size': args.batch_size, 'learning_rate': args.lr,
'seed': args.seed, 'teacher_models_run': [], 'contains_synthetic': False,
'rendered_mesh_proxies': True, 'optimizer': 'fresh AdamW, resumed on interruption',
'data': read(folder / 'preparation.json')}
step, best_step = 0, 0
if (run / 'last.pt').exists():
last = torch.load(run / 'last.pt', map_location='cpu', weights_only=True)
model.load_state_dict(last['model'])
optimizer.load_state_dict(last['optimizer'])
rng.setstate(last['python_rng'])
step, best_step, best_loss = last['step'], last['best_step'], last['best_loss']
meta = last['metadata']
log(resuming_from_step=step)
else:
meta['baseline_validation'] = evaluate(model, val_loader, device)
best_loss = meta['baseline_validation']['loss']
if not math.isfinite(best_loss):
raise RuntimeError('Nonfinite baseline evaluation.')
dump(run / 'training.json', meta)
started = time.monotonic()
starting_step = step
def save(path, include_optimizer):
payload = {'format': 1, 'config': model.config, 'model': cpu_tree(model.state_dict()),
'step': step, 'metadata': meta}
if include_optimizer:
payload.update(optimizer=cpu_tree(optimizer.state_dict()), python_rng=rng.getstate(),
best_step=best_step, best_loss=best_loss)
tmp = path.with_suffix('.tmp')
torch.save(payload, tmp)
tmp.replace(path)
try:
while step < args.steps:
images, target = default_collate([train_data[rng.randrange(len(train_data))]
for _ in range(args.batch_size)])
model.train()
optimizer.zero_grad(set_to_none=True)
prediction = model(images.to(device))
loss, _ = distillation_loss(prediction, on_device(target, device), model.grid)
if not torch.isfinite(loss):
raise RuntimeError('Nonfinite training loss; publication disabled.')
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1., error_if_nonfinite=True)
optimizer.step()
step += 1
if step == 1 or step % 50 == 0:
log(step=step, total=args.steps, loss=float(loss.detach()),
elapsed_seconds=round(time.monotonic() - started),
steps_per_second=round((step - starting_step) / max(time.monotonic()-started, .001), 3))
if step % args.eval_every == 0 or step == args.steps:
metrics = evaluate(model, val_loader, device)
if not all(math.isfinite(v) for v in metrics.values()):
raise RuntimeError('Nonfinite validation metrics.')
if metrics['loss'] < best_loss:
best_loss, best_step = metrics['loss'], step
save(run / 'best.pt', False)
record = {'step': step, 'validation': metrics, 'best_step': best_step}
with (run / 'metrics.jsonl').open('a') as f:
f.write(json.dumps(record) + '\n')
log(**record)
save(run / 'last.pt', True)
finally:
if step:
save(run / 'last.pt', True)
if step != args.steps:
raise RuntimeError('Training incomplete; no publication.')
if not best_step:
raise RuntimeError('Validation did not improve over v2. Checkpoints are local; no publication.')
dump(run / 'completed.json', {'completed_steps': step, 'selected_step': best_step,
'best_validation_loss': best_loss})
return run
def assess(root, source, folder, run, args):
import torch
from torch.utils.data import DataLoader
from image3d_student.data import CachedDataset
from image3d_student.model import Student
from image3d_student.engine import device_for, evaluate
device = device_for(args.device)
loader = DataLoader(CachedDataset(folder / 'test.jsonl'), batch_size=1, num_workers=0)
result = {}
for name, checkpoint in [('base_v2', source / 'model.pt'), ('candidate_v3', run / 'best.pt')]:
state = torch.load(checkpoint, map_location='cpu', weights_only=True)
model = Student(**state['config']).to(device)
model.load_state_dict(state['model'], strict=True)
metrics = evaluate(model, loader, device)
if not all(math.isfinite(v) for v in metrics.values()):
raise RuntimeError('Nonfinite test metrics.')
result[name] = {'sha256': sha(checkpoint), 'metrics': metrics}
del model
before, after = result['base_v2']['metrics'], result['candidate_v3']['metrics']
meta = read(run / 'training.json')
completed = read(run / 'completed.json')
gates = {'training_complete': completed['completed_steps'] == args.steps,
'validation_loss_improved': completed['best_validation_loss'] < meta['baseline_validation']['loss'],
'test_loss_improved_at_least_half_percent': after['loss'] <= before['loss'] * .995,
'test_occupancy_iou_not_worse': after['occupancy_iou'] >= before['occupancy_iou'],
'weights_changed': result['base_v2']['sha256'] != result['candidate_v3']['sha256']}
report = {**result, 'gates': gates, 'passed': all(gates.values()),
'test_manifest_sha256': sha(folder / 'test.jsonl'),
'measurement': 'mean per-view proxy loss and occupancy IoU, threshold 0.5, batch size 1',
'limitation': 'Not evidence of natural-photo, mesh, texture or photorealistic rendering quality.'}
dump(root / 'evaluation.json', report)
log(evaluation=report)
return report
def package(root, source, folder, run, report):
release = root / 'release'
release.mkdir(exist_ok=True)
shutil.copy2(run / 'best.pt', release / 'model.pt')
for name in ('LICENSE', 'NOTICE', 'THIRD_PARTY.md', 'config.json'):
shutil.copy2(source / name, release / name)
for name in ('src', 'licenses'):
shutil.copytree(source / name, release / name, dirs_exist_ok=True,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc'))
project = (source / 'pyproject.toml').read_text().replace('forma3d-tiny-2', 'forma3d-tiny-3')
project = project.replace('version = "0.3.0"', 'version = "0.4.0"')
(release / 'pyproject.toml').write_text(project)
shutil.copy2(folder / 'LICENSE-ABO-CC-BY-4.0.txt', release / 'licenses/LICENSE-ABO-CC-BY-4.0.txt')
for path in (run / 'training.json', run / 'completed.json', run / 'metrics.jsonl',
folder / 'preparation.json', root / 'evaluation.json', root / 'settings.json'):
shutil.copy2(path, release / path.name)
dump(release / 'ATTRIBUTIONS.json', {'inherited': read(source / 'ATTRIBUTIONS.json'),
'abo_continuation': read(folder / 'sources.json')})
(release / 'splits').mkdir(exist_ok=True)
for name in ('train', 'validation', 'test'):
shutil.copy2(folder / f'{name}.jsonl', release / 'splits' / f'{name}.jsonl')
recipe = release / 'recipe'
recipe.mkdir(exist_ok=True)
for name in ('pipeline.py', 'decode.mjs', 'requirements.txt', 'package.json', 'package-lock.json', 'run.command'):
shutil.copy2(HERE / name, recipe / name)
inherited_notice = (release / 'NOTICE').read_text()
(release / 'NOTICE').write_text(inherited_notice + '\nABO continuation data: Amazon.com, Amazon Berkeley '
'Objects contributors; converted to GLB by CodePawl. CC BY 4.0. Training preprocessing '
'decompresses geometry, normalizes objects, samples surface colors and renders Gaussian proxies. '
'See ATTRIBUTIONS.json and licenses/LICENSE-ABO-CC-BY-4.0.txt. No endorsement implied.\n')
base = report['base_v2']['metrics']
candidate = report['candidate_v3']['metrics']
prep = read(folder / 'preparation.json')
completed = read(run / 'completed.json')
(release / 'README.md').write_text(f'''---
license: apache-2.0
pipeline_tag: image-to-3d
base_model: {BASE}
base_model_relation: finetune
model_name: Forma3D Tiny 3
datasets:
- codepawl/abo-glb
tags:
- pytorch
- gaussian-splatting
- mesh-proxy-supervision
- research-prototype
---
# Forma3D Tiny 3
Continued from [{BASE}](https://huggingface.co/{BASE}) at `{BASE_REV}`.
Same 306,321-parameter architecture, 16 cubed grid, width 64, maximum 4,096 Gaussian slots.
Fresh AdamW optimizer at the start of continuation. Completed {completed['completed_steps']} steps;
selected step {completed['selected_step']} by validation loss only.
## Data and scope
{prep['objects']} ABO objects, {prep['views']} orthographic Gaussian-proxy images on black.
Data revision: `{DATA_REV}`. Targets are isotropic mesh surface samples, compressed to
one Gaussian per occupied grid cell. Texture surface colors are sampled; full PBR rendering
and differentiable rendering are not used. No natural-photo training is claimed.
MeshAnythingV2, Idol Diffusers, SHARP, Pixal3D, Idol Turbo and Qwen3.5-4B-3D-Coder
were **not run, merged, or distilled** in this Mac-only recipe.
Objects, shared Amazon item IDs and exact mesh/image/target hashes are grouped before splitting.
Split groups: {json.dumps(prep['groups'])}. Test data is excluded from training and checkpoint selection.
Near duplicates can remain; overlap with ancestor training datasets is not fully known.
## Evaluation
Both checkpoints were evaluated on the identical held-out ABO proxy views.
| Metric | Starting v2 | Selected v3 |
|---|---:|---:|
| Mean test loss | {base['loss']:.6f} | {candidate['loss']:.6f} |
| Mean view occupancy IoU, threshold 0.5 | {base['occupancy_iou']:.6f} | {candidate['occupancy_iou']:.6f} |
These measurements do not establish natural-photo reconstruction, photorealism, mesh quality,
novel-view rendering quality or improvement on v2's original domains. No confidence intervals
or broad generalization claim. See evaluation.json, preparation.json and split manifests.
## Use
With Python 3.11, install this repository using `pip install .`, then:
```bash
image3d generate --checkpoint model.pt --image centered-object-on-black.png --out object.ply --device mps
image3d preview --ply object.ply --out preview.png
```
Output is Gaussian PLY, not a textured GLB mesh. Inputs should match centered objects on black.
The model has no text prompt conditioning. Reproduction launcher and locked dependencies are in recipe/.
## Attribution
Publisher source and weights: Apache 2.0. Retain inherited NOTICE, THIRD_PARTY.md and ATTRIBUTIONS.json.
ABO models, images and listing text: Amazon.com and the Amazon Berkeley Objects contributors,
CC BY 4.0; converted to GLB by CodePawl. The recipe further decompresses geometry,
normalizes coordinates, samples colored surfaces and renders proxy images.
See [ABO dataset](https://huggingface.co/datasets/codepawl/abo-glb),
ATTRIBUTIONS.json and licenses/LICENSE-ABO-CC-BY-4.0.txt. Raw assets are not bundled.
''')
freeze = ''.join(sorted(f"{d.metadata['Name']}=={d.version}\n"
for d in importlib.metadata.distributions()))
(release / 'environment.txt').write_text(freeze)
sums = ''.join(f'{sha(p)} {p.relative_to(release)}\n' for p in sorted(release.rglob('*'))
if p.is_file() and p.name != 'SHA256SUMS')
(release / 'SHA256SUMS').write_text(sums)
return release
def publish(api, root, release):
from huggingface_hub import hf_hub_download
from huggingface_hub.errors import RepositoryNotFoundError, EntryNotFoundError
marker = root / 'publication.json'
expected = sha(release / 'model.pt')
try:
info = api.model_info(DEST)
except RepositoryNotFoundError:
info = None
if info is not None:
try:
remote = hf_hub_download(DEST, 'model.pt', revision=info.sha, token=api.token)
except EntryNotFoundError:
if not marker.exists():
raise RuntimeError(f'{DEST} already exists. Refusing to overwrite an unrelated repository.')
else:
if sha(remote) == expected:
print(f'Already published and verified: https://huggingface.co/{DEST}', flush=True)
return
raise RuntimeError(f'{DEST} already has different weights; refusing to overwrite them.')
else:
api.create_repo(DEST, repo_type='model', private=False, exist_ok=False)
dump(marker, {'repo_id': DEST, 'expected_model_sha256': expected})
commit = api.upload_folder(repo_id=DEST, repo_type='model', folder_path=release,
commit_message='Publish evaluated Forma3D Tiny 3 ABO continuation')
remote = hf_hub_download(DEST, 'model.pt', revision=commit.oid, token=api.token)
if sha(remote) != expected:
raise RuntimeError('Uploaded checkpoint verification failed.')
dump(marker, {'repo_id': DEST, 'model_sha256': expected, 'commit': commit.oid, 'verified': True})
print(f'Published and verified: https://huggingface.co/{DEST}', flush=True)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--root', type=Path, default=HERE / 'run')
p.add_argument('--objects', type=int, default=512)
p.add_argument('--steps', type=int, default=6000)
p.add_argument('--batch-size', type=int, default=4)
p.add_argument('--lr', type=float, default=5e-5)
p.add_argument('--eval-every', type=int, default=250)
p.add_argument('--seed', type=int, default=44)
p.add_argument('--device', choices=['mps', 'cpu'], default='mps')
p.add_argument('--publish', action='store_true')
p.add_argument('--prepare-only', action='store_true')
args = p.parse_args()
if args.objects < 12 or min(args.steps, args.batch_size, args.eval_every) < 1 or args.lr <= 0:
p.error('Need at least 12 objects and positive training settings.')
if args.publish and (args.objects < 128 or args.steps < 1000 or args.device != 'mps'):
p.error('Publication requires >=128 objects, >=1000 steps and MPS; small runs are tests only.')
root = args.root.resolve()
root.mkdir(parents=True, exist_ok=True)
with (root / '.lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError('Another run is already using this directory.')
settings = {k: v for k, v in vars(args).items() if k not in ('root', 'publish', 'prepare_only')}
settings.update(base_revision=BASE_REV, data_revision=DATA_REV, recipe_sha256=sha(__file__))
settings_path = root / 'settings.json'
if settings_path.exists() and read(settings_path) != settings:
raise RuntimeError('Run settings/code changed. Use --root with a new directory.')
dump(settings_path, settings)
if shutil.disk_usage(root).free < 8 * 1024**3:
raise RuntimeError('Need at least 8 GiB free disk space before starting.')
api = authenticate() if args.publish and not args.prepare_only else None
source, helper = upstream(root)
folder = prepare(root, source, helper, args)
if args.prepare_only:
return
run = train(root, source, folder, args)
report = assess(root, source, folder, run, args)
if not report['passed']:
print('Release checks did not pass. Results and checkpoints are saved locally; nothing published.')
sys.exit(2)
release = package(root, source, folder, run, report)
if args.publish:
publish(api, root, release)
else:
print(f'Local test only; no publication. Release package: {release}')
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\nInterrupted. Rerun the same command to continue from saved progress.', file=sys.stderr)
sys.exit(130)