← back to Dw Photo Capture
visual-search/train/finetune.py
101 lines
#!/usr/bin/env python3
# Fine-tune open_clip ViT-B-32 (laion2b) on the DW catalog so image embeddings specialize to
# wallcovering/fabric patterns → sharper "really ID the pattern" retrieval than off-the-shelf CLIP.
# Same architecture + pretrained tag as the live search service, so the fine-tuned visual weights
# are a DROP-IN replacement. Standard symmetric CLIP contrastive (InfoNCE) loss. Runs on Apple MPS.
#
# PYTORCH_ENABLE_MPS_FALLBACK=1 ./venv/bin/python finetune.py --epochs 4 --batch 96
import os, sys, json, time, argparse, random
os.environ.setdefault('PYTORCH_ENABLE_MPS_FALLBACK', '1')
import torch, torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
from PIL import Image
import open_clip
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, 'cache'); CKPT = os.path.join(HERE, 'ckpt'); os.makedirs(CKPT, exist_ok=True)
Image.MAX_IMAGE_PIXELS = None
def clean(s): return ' '.join((s or '').replace('|', ' ').split())[:120]
def caption(pattern, color, vendor, ptype):
# natural DW-domain caption: "<pattern> in <color>, <vendor> <type>"
bits = [clean(pattern)]
if color and color.strip(): bits.append('in ' + clean(color))
tail = ' '.join(x for x in [clean(vendor), clean(ptype).lower()] if x)
txt = ', '.join([b for b in bits if b])
if tail: txt += ', ' + tail
return txt.lower().strip(', ')
class DWSet(Dataset):
def __init__(self, rows, preprocess, tokenizer):
self.rows = rows; self.pp = preprocess; self.tok = tokenizer
def __len__(self): return len(self.rows)
def __getitem__(self, i):
idv, pat, col, ven, typ = self.rows[i]
img = self.pp(Image.open(os.path.join(CACHE, idv + '.jpg')).convert('RGB'))
txt = self.tok([caption(pat, col, ven, typ)])[0]
return img, txt
def load_rows():
rows = []
with open(os.path.join(HERE, 'manifest.tsv')) as f:
for line in f:
p = line.rstrip('\n').split('\t')
if len(p) < 6: continue
idv = p[0]
if os.path.exists(os.path.join(CACHE, idv + '.jpg')):
rows.append((idv, p[2], p[3], p[4], p[5])) # id, pattern, color, vendor, type
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--epochs', type=int, default=4)
ap.add_argument('--batch', type=int, default=96)
ap.add_argument('--lr', type=float, default=3e-6) # gentle — avoid catastrophic forgetting
ap.add_argument('--max_hours', type=float, default=7.5)
a = ap.parse_args()
dev = 'mps' if torch.backends.mps.is_available() else 'cpu'
print(f"device={dev}", flush=True)
model, _, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k')
tokenizer = open_clip.get_tokenizer('ViT-B-32')
model = model.to(dev).float(); model.train()
rows = load_rows()
if len(rows) < 64:
print(f"only {len(rows)} cached images — need more; is download.py done? exiting", flush=True); return
random.seed(0); random.shuffle(rows)
print(f"training pairs (cached): {len(rows)}", flush=True)
ds = DWSet(rows, preprocess, tokenizer)
dl = DataLoader(ds, batch_size=a.batch, shuffle=True, num_workers=4, drop_last=True, persistent_workers=True)
opt = torch.optim.AdamW(model.parameters(), lr=a.lr, weight_decay=0.1)
t0 = time.time(); step = 0
for ep in range(a.epochs):
for imgs, txts in dl:
imgs = imgs.to(dev); txts = txts.to(dev)
imf = F.normalize(model.encode_image(imgs), dim=-1)
txf = F.normalize(model.encode_text(txts), dim=-1)
scale = model.logit_scale.exp().clamp(max=100)
logits = scale * imf @ txf.t()
labels = torch.arange(logits.size(0), device=dev)
loss = (F.cross_entropy(logits, labels) + F.cross_entropy(logits.t(), labels)) / 2
opt.zero_grad(); loss.backward(); opt.step()
with torch.no_grad(): model.logit_scale.clamp_(0, 4.6052)
step += 1
if step % 20 == 0:
el = (time.time() - t0) / 3600
print(f"ep{ep} step{step} loss={loss.item():.4f} elapsed={el:.2f}h", flush=True)
if (time.time() - t0) / 3600 > a.max_hours:
print("max_hours reached — stopping", flush=True)
torch.save(model.state_dict(), os.path.join(CKPT, 'dw_clip_ft.pt')); return
ckpt = os.path.join(CKPT, f'dw_clip_ft_ep{ep}.pt')
torch.save(model.state_dict(), ckpt)
print(f"saved {ckpt}", flush=True)
torch.save(model.state_dict(), os.path.join(CKPT, 'dw_clip_ft.pt'))
print(f"DONE — final weights → ckpt/dw_clip_ft.pt ({(time.time()-t0)/3600:.2f}h)", flush=True)
if __name__ == '__main__':
main()