feat: consolidate backend and docker-compose setup

This commit is contained in:
Rafhan Mazaya Fathurrahman committed 2026-06-30 21:09:08 +07:00
commit ff3753a745
306 files changed
+35450

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""
Ultralytics YOLO Classification Training Script
Trains a product-packaging classifier from class folders in `foto-kemasan-v2`.
Each subfolder under `foto-kemasan-v2/` is one product class; images live directly
inside that folder.
Usage (from repo root or this directory):
# 1) Train the model (defaults to foto-kemasan-v2, 100 epochs)
uv run python pfm-web-app/public/produk-pfm/train_classifier.py train --imgsz 224
# 2) Run prediction on an image using the trained weights
uv run python pfm-web-app/public/produk-pfm/train_classifier.py predict \\
--image "pfm-web-app/public/produk-pfm/foto-kemasan-v2/15030101 FIESTA CRINKLE CUT 500 GR/WhatsApp Image 2026-05-28 at 11.46.31.jpeg"
"""
import os
import re
import sys
import shutil
import random
import argparse
from datetime import date
from pathlib import Path
import torch
try:
from ultralytics import YOLO
except ImportError:
print("Error: 'ultralytics' library not found. Please install it using: uv add ultralytics")
sys.exit(1)
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_SPLIT_DIR = SCRIPT_DIR / "yolo_dataset"
DEFAULT_MODEL = SCRIPT_DIR / "yolo26n-cls.pt"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_PROJECT = SCRIPT_DIR / "runs" / "classify"
DEFAULT_EPOCHS = 100
def classifier_output_path(epochs: int = DEFAULT_EPOCHS, run_date: date | None = None) -> Path:
"""Build the dated classifier artifact path under models/."""
run_date = run_date or date.today()
return DEFAULT_MODELS_DIR / f"produk-pfm-classifier-26n-{epochs}e-{run_date:%Y-%m-%d}.pt"
def _classifier_date_from_name(path: Path) -> date | None:
match = re.search(
r"produk-pfm-classifier-26n-\d+e-(\d{4}-\d{2}-\d{2})\.pt$",
path.name,
)
if not match:
return None
year, month, day = (int(part) for part in match.group(1).split("-"))
return date(year, month, day)
def latest_classifier_weights(models_dir: Path = DEFAULT_MODELS_DIR) -> Path:
"""Return the newest produk-pfm-classifier weights in models/, if any."""
if not models_dir.is_dir():
return classifier_output_path()
candidates = list(models_dir.glob("produk-pfm-classifier-26n-*e-*.pt"))
if not candidates:
return classifier_output_path()
def sort_key(path: Path) -> tuple[date, float]:
name_date = _classifier_date_from_name(path) or date.min
return (name_date, path.stat().st_mtime)
return max(candidates, key=sort_key)
VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
def is_image_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in VALID_IMAGE_EXTENSIONS
def split_dataset(src_dir: Path, dest_dir: Path, split_ratio: float = 0.8, seed: int = 42):
"""
Split class folders from src_dir into train/val folders in dest_dir.
Ensures every class with 2+ images keeps at least one image in validation.
"""
random.seed(seed)
train_dir = dest_dir / "train"
val_dir = dest_dir / "val"
if dest_dir.exists():
print(f"Cleaning existing split directory: {dest_dir}")
shutil.rmtree(dest_dir)
train_dir.mkdir(parents=True, exist_ok=True)
val_dir.mkdir(parents=True, exist_ok=True)
exclude_dirs = {dest_dir.name, "train", "val"}
class_dirs = [d for d in src_dir.iterdir() if d.is_dir() and d.name not in exclude_dirs]
class_dirs.sort()
print(f"Found {len(class_dirs)} product classes in {src_dir}")
total_train = 0
total_val = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if is_image_file(f)],
key=lambda p: p.name,
)
random.shuffle(images)
num_images = len(images)
if num_images == 0:
print(f"Warning: Class '{class_name}' has 0 images. Skipping.")
continue
class_train_dir = train_dir / class_name
class_val_dir = val_dir / class_name
class_train_dir.mkdir(parents=True, exist_ok=True)
class_val_dir.mkdir(parents=True, exist_ok=True)
if num_images == 1:
train_images = images
val_images = images
elif num_images == 2:
train_images = [images[0]]
val_images = [images[1]]
else:
split_idx = max(1, int(num_images * split_ratio))
split_idx = min(split_idx, num_images - 1)
train_images = images[:split_idx]
val_images = images[split_idx:]
for img in train_images:
shutil.copy(img, class_train_dir / img.name)
total_train += 1
for img in val_images:
shutil.copy(img, class_val_dir / img.name)
total_val += 1
print(
f" Class '{class_name}': {len(train_images)} train, "
f"{len(val_images)} val (total: {num_images})"
)
print(f"Dataset split completed: {total_train} train images, {total_val} validation images.")
print(f"Split dataset located at: {dest_dir.absolute()}")
def train_model(args):
"""Handles training the YOLO classification model."""
src_path = Path(args.src_dir).resolve()
dest_path = Path(args.split_dir).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
sys.exit(1)
print(f"--- Preparing Dataset from {src_path} ---")
split_dataset(src_path, dest_path, split_ratio=args.split_ratio)
model_path = Path(args.model).resolve()
print(f"\n--- Initializing YOLO Model ({model_path}) ---")
model = YOLO(str(model_path))
if args.device:
device = args.device
else:
device = "0" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
print("\n--- Starting Training ---")
results = model.train(
data=str(dest_path),
epochs=args.epochs,
imgsz=args.imgsz,
batch=args.batch,
device=device,
project=str(Path(args.project).resolve()),
name=args.name,
exist_ok=True,
workers=args.workers,
lr0=args.lr,
optimizer=args.optimizer,
seed=42,
)
best_weights = Path(results.save_dir) / "weights" / "best.pt"
output_path = Path(args.output).resolve() if args.output else classifier_output_path(args.epochs)
output_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(best_weights, output_path)
print("\nTraining completed successfully!")
print(f"Run weights saved at: {best_weights}")
print(f"Published model saved at: {output_path}")
if args.export:
print("\n--- Exporting model to ONNX format ---")
try:
export_model = YOLO(str(output_path))
onnx_path = Path(export_model.export(format="onnx"))
dated_onnx = output_path.with_suffix(".onnx")
if onnx_path.resolve() != dated_onnx.resolve():
shutil.copy2(onnx_path, dated_onnx)
print(f"Model exported successfully to: {dated_onnx}")
except Exception as e:
print(f"Warning: ONNX export failed: {e}")
print("\nYou can run predictions with:")
print(f" uv run python {Path(__file__).name} predict --image <image_path> --model {output_path}")
def predict_image(args):
"""Runs classification inference on a single image."""
model_path = Path(args.model).resolve()
image_path = Path(args.image).resolve()
if not model_path.exists():
print(f"Error: Model weights not found at {model_path}")
sys.exit(1)
if not image_path.exists():
print(f"Error: Target image file not found at {image_path}")
sys.exit(1)
print(f"Loading model from {model_path}...")
model = YOLO(str(model_path))
print(f"Running prediction on {image_path}...")
results = model(str(image_path))
for result in results:
probs = result.probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = result.names[top1_idx]
print("\n=== Classification Results ===")
print(f"Top-1 Prediction: {top1_name} (Confidence: {top1_conf:.4f})")
print("\nAll Probabilities:")
sorted_probs = sorted(
[(result.names[i], float(val)) for i, val in enumerate(probs.data)],
key=lambda x: x[1],
reverse=True,
)
for name, score in sorted_probs:
print(f" {name}: {score:.4f}")
def main():
parser = argparse.ArgumentParser(
description="Ultralytics YOLO classification utility for produk-pfm packaging photos."
)
subparsers = parser.add_subparsers(dest="command", required=True, help="Command to run")
train_parser = subparsers.add_parser("train", help="Train a classification model")
train_parser.add_argument(
"--src-dir",
type=str,
default=str(DEFAULT_DATASET_DIR),
help=f"Source dataset directory with one class folder per product (default: {DEFAULT_DATASET_DIR.name})",
)
train_parser.add_argument(
"--split-dir",
type=str,
default=str(DEFAULT_SPLIT_DIR),
help="Output split dataset directory",
)
train_parser.add_argument(
"--split-ratio",
type=float,
default=0.8,
help="Train/val split ratio for classes with 3+ images (default: 0.8)",
)
train_parser.add_argument(
"--model",
type=str,
default=str(DEFAULT_MODEL),
help="Pretrained model (e.g. yolo26n-cls.pt, yolo11n-cls.pt, yolov8n-cls.pt)",
)
train_parser.add_argument(
"--epochs",
type=int,
default=DEFAULT_EPOCHS,
help=f"Number of training epochs (default: {DEFAULT_EPOCHS})",
)
train_parser.add_argument(
"--output",
type=str,
default=None,
help=(
"Published .pt output path (default: "
"models/produk-pfm-classifier-26n-{epochs}e-{YYYY-MM-DD}.pt)"
),
)
train_parser.add_argument("--imgsz", type=int, default=224, help="Target image size for classification")
train_parser.add_argument("--batch", type=int, default=8, help="Batch size for training")
train_parser.add_argument(
"--device",
type=str,
default=None,
help="Device to run on (e.g. 0 or 'cpu'). Default is GPU if available.",
)
train_parser.add_argument(
"--project",
type=str,
default=str(DEFAULT_PROJECT),
help="Project output folder name",
)
train_parser.add_argument("--name", type=str, default="train", help="Experiment name")
train_parser.add_argument("--workers", type=int, default=4, help="Number of data loading workers")
train_parser.add_argument("--lr", type=float, default=0.01, help="Initial learning rate")
train_parser.add_argument(
"--optimizer",
type=str,
default="auto",
choices=["SGD", "Adam", "AdamW", "RMSProp", "auto"],
help="Optimizer to use",
)
train_parser.add_argument(
"--export",
action="store_true",
default=True,
help="Export model to ONNX after training",
)
predict_parser = subparsers.add_parser("predict", help="Predict class of an image")
predict_parser.add_argument("--image", type=str, required=True, help="Path to image file")
predict_parser.add_argument(
"--model",
type=str,
default=str(latest_classifier_weights()),
help="Path to trained YOLO .pt model weights (default: newest models/produk-pfm-classifier-*.pt)",
)
args = parser.parse_args()
if args.command == "train":
train_model(args)
elif args.command == "predict":
predict_image(args)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B