feat: a store's fine-tuned head, and the two-stage gate it needs
The head is now part of a store rather than a script: `finetune_identity.py --stock stock-lopia` trains on that store's enrolled views (27 views over 7 products, 12 augmented copies each) and writes `<stock>/identity_head.pt`, which ProductGallery loads automatically. The first attempt exposed a trap worth recording. A discriminative head has no "none of the above": trained with an angular margin to separate seven products, it maps everything confidently onto those seven, including cartons it has never seen. raw features, threshold 0.55 home shelf 78% accepted foreign shelves 5% accepted trained head, threshold 0.40 home shelf 58% accepted foreign shelves 44% accepted Rejection - the gallery's whole point - disappears. The fix needs no retraining: the two mechanisms fail in opposite directions, so raw features decide *whether* to answer (they generalise) and the head decides *what* to answer (it discriminates). Measured: 47% accepted on the home shelf, 5% on foreign ones. Lower coverage than raw-only, which is the honest trade. The head also needs its own threshold (0.40 against the raw 0.55): a trained head's similarity scale is its own, and reusing the raw line rejected almost everything it said. 175 tests pass. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
4fd5059ef0
commit
2bf916f2aa
4 changed files with 192 additions and 2 deletions
|
|
@ -133,13 +133,84 @@ def train_head(features, labels, class_ids, epochs, device, margin: float = 0.0,
|
|||
return projection, torch.nn.functional.normalize(prototypes.detach(), dim=1)
|
||||
|
||||
|
||||
def train_from_stock(args) -> None:
|
||||
"""Train the head on a store's own enrolled views, for the gallery to load.
|
||||
|
||||
The head belongs to the store: the views in its stock directory are its products,
|
||||
and a head trained on them is what turns the gallery from a lookup that cannot
|
||||
tell two milk cartons apart into one that can. One to four views per SKU is
|
||||
enough, with augmentation standing in for the rest.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
|
||||
import torch
|
||||
|
||||
from gallery import ProductGallery
|
||||
|
||||
stock = args.stock
|
||||
gallery = ProductGallery(stock, [])
|
||||
# the SKUs are whatever the stock directory holds
|
||||
skus = sorted({os.path.splitext(name)[0].rsplit("_", 1)[0] if os.path.splitext(name)[0].rsplit("_", 1)[-1].isdigit()
|
||||
else os.path.splitext(name)[0]
|
||||
for name in os.listdir(stock)
|
||||
if os.path.splitext(name)[1].lower() in (".jpeg", ".jpg", ".png")})
|
||||
views = {sku: gallery._views_for(sku) for sku in skus}
|
||||
views = {sku: paths for sku, paths in views.items() if paths}
|
||||
if len(views) < 2:
|
||||
print(f"{stock}: needs at least two products with views, found {len(views)}")
|
||||
return
|
||||
|
||||
crops, labels = [], []
|
||||
for sku, paths in sorted(views.items()):
|
||||
for path in paths:
|
||||
image = cv2.imread(path)
|
||||
if image is not None and image.size:
|
||||
crops.append(image)
|
||||
labels.append(sku)
|
||||
class_ids = sorted(views)
|
||||
print(f"{stock}: {len(crops)} views over {len(class_ids)} products")
|
||||
|
||||
model, device = build_model()
|
||||
rng = np.random.RandomState(11)
|
||||
augmented, augmented_labels = [], []
|
||||
for crop, label in zip(crops, labels):
|
||||
augmented.append(crop)
|
||||
augmented_labels.append(label)
|
||||
for _ in range(args.augment):
|
||||
augmented.append(augment(crop, rng))
|
||||
augmented_labels.append(label)
|
||||
print(f"embedding {len(augmented)} crops on {device}...")
|
||||
features = embed(model, device, augmented)
|
||||
|
||||
projection, prototypes = train_head(features, augmented_labels, class_ids,
|
||||
args.epochs, device, margin=0.25)
|
||||
payload = {
|
||||
"class_ids": class_ids,
|
||||
"image_size": 518,
|
||||
"base_model": "vit_small_patch14_dinov2.lvd142m",
|
||||
"projection": {key: value.cpu() for key, value in projection.state_dict().items()},
|
||||
"prototypes": prototypes.cpu(),
|
||||
"views": {sku: len(paths) for sku, paths in sorted(views.items())},
|
||||
}
|
||||
path = os.path.join(stock, "identity_head.pt")
|
||||
torch.save(payload, path)
|
||||
print(f"wrote {path} ({len(class_ids)} products, {len(crops)} views)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--epochs", type=int, default=60)
|
||||
parser.add_argument("--views", type=int, default=2, help="enrolment views per class")
|
||||
parser.add_argument("--augment", type=int, default=12, help="augmented copies per enrolment view")
|
||||
parser.add_argument("--image", default=os.path.expanduser("~/dorayaki-data/shelf-images"))
|
||||
parser.add_argument("--stock", default=None,
|
||||
help="train on a store's enrolled views instead of the milk shelf, "
|
||||
"and write <stock>/identity_head.pt for the gallery to load")
|
||||
args = parser.parse_args()
|
||||
if args.stock:
|
||||
train_from_stock(args)
|
||||
return
|
||||
|
||||
import json
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue