diff --git a/examples/meter-detection/README.md b/examples/meter-detection/README.md index 0cf07b0..f51ab26 100644 --- a/examples/meter-detection/README.md +++ b/examples/meter-detection/README.md @@ -153,7 +153,7 @@ Or pass the job name explicitly: qc-cli train status qc-cli-YYYYMMDD-HHMMSS ``` -## Outputs +## SageMaker Outputs When the job completes, SageMaker packages the files written under `/opt/ml/model` into `model.tar.gz`. @@ -167,6 +167,91 @@ metrics.json The archive is stored under the configured `s3.model_prefix`. +## 6. Configure Qualcomm AI Hub + +Authenticate with Qualcomm AI Hub: + +```bash +qai-hub configure --api_token +``` + +Add AI Hub settings to `config.yaml`. The input name and image size must match the ONNX model exported by this example: + +```yaml +aihub: + device: + name: Dragonwing IQ-9075 EVK + target_runtime: tflite + input_specs: + images: [[1, 3, 640, 640], float32] + job_name: meter-detection + model_name: meter-detection + output_dir: build/qai-hub/meter-detection +``` + +Use the same image size configured in `sagemaker.training.hyperparameters.imgsz`. For example, a smoke-test model +trained with `imgsz: 320` requires `images: [[1, 3, 320, 320], float32]`. + +## 7. Prepare AI Hub Inputs + +Generate calibration samples and a validation input from the downloaded dataset: + +```bash +uv run python examples/meter-detection/prepare_aihub_inputs.py --image-size 640 +``` + +This writes: + +```text +examples/meter-detection/data/aihub_calibration/*.npy +examples/meter-detection/data/inputs.npz +``` + +The script applies the preprocessing expected by the exported YOLO model: aspect-ratio-preserving letterboxing, +RGB channel order, channel-first layout, and pixel values normalized to `[0, 1]`. + +Set `--image-size` to the training `imgsz` value when it is not `640`. + +## 8. Upload To Qualcomm AI Hub + +Use the SageMaker job name printed by `qc-cli train start`: + +```bash +qc-cli ai-hub upload \ + examples/meter-detection/data/aihub_calibration \ + examples/meter-detection/data/inputs.npz \ + --from-job qc-cli-YYYYMMDD-HHMMSS +``` + +The command downloads the job's `model.tar.gz`, finds `model.onnx`, uploads it to AI Hub, and runs quantization, +compilation, validation, and profiling. The uploaded source model uses the configured +`aihub.model_name`. + +If the meter-detection job is still the last training job in `.qc-cli.json`, `--from-job` can be omitted. Keeping it +explicit prevents accidentally uploading an artifact from a different training run. + +To resume after a completed step, use one of: + +```bash +qc-cli ai-hub upload \ + examples/meter-detection/data/aihub_calibration \ + examples/meter-detection/data/inputs.npz \ + --from-step compile +``` + +```bash +qc-cli ai-hub upload \ + examples/meter-detection/data/aihub_calibration \ + examples/meter-detection/data/inputs.npz \ + --from-step validate +``` + +Download the compiled artifact after the workflow completes: + +```bash +qc-cli ai-hub download --output build/qai-hub/meter-detection/model.tflite +``` + ## Training Hyperparameters Values under `sagemaker.training.hyperparameters` are passed to `source/train.py` as command-line arguments. diff --git a/examples/meter-detection/prepare_aihub_inputs.py b/examples/meter-detection/prepare_aihub_inputs.py new file mode 100644 index 0000000..6b0e5a5 --- /dev/null +++ b/examples/meter-detection/prepare_aihub_inputs.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Prepare Qualcomm AI Hub calibration and validation inputs for the meter detector.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +from PIL import Image + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dataset-dir", + type=Path, + default=Path("examples/meter-detection/data/electric-meter-detection"), + help="Root of the extracted Roboflow dataset.", + ) + parser.add_argument( + "--calibration-dir", + type=Path, + default=Path("examples/meter-detection/data/aihub_calibration"), + help="Directory where .npy calibration samples will be written.", + ) + parser.add_argument( + "--input-file", + type=Path, + default=Path("examples/meter-detection/data/inputs.npz"), + help="Validation .npz input file for qc-cli ai-hub validate.", + ) + parser.add_argument("--input-name", default="images", help="ONNX input name.") + parser.add_argument("--image-size", type=int, default=640, help="Square image size used for ONNX export.") + parser.add_argument("--samples", type=int, default=16, help="Number of calibration samples to write.") + return parser.parse_args() + + +def preprocess_image(path: Path, image_size: int) -> np.ndarray: + """Apply Ultralytics-style letterboxing and produce an NCHW float32 tensor.""" + with Image.open(path) as source: + image = source.convert("RGB") + + scale = min(image_size / image.width, image_size / image.height) + resized_width = round(image.width * scale) + resized_height = round(image.height * scale) + image = image.resize((resized_width, resized_height), Image.Resampling.BILINEAR) + + canvas = Image.new("RGB", (image_size, image_size), (114, 114, 114)) + left = round((image_size - resized_width) / 2 - 0.1) + top = round((image_size - resized_height) / 2 - 0.1) + canvas.paste(image, (left, top)) + + array = np.asarray(canvas, dtype=np.float32) / 255.0 + return np.transpose(array, (2, 0, 1))[None, ...].astype(np.float32) + + +def main() -> None: + args = parse_args() + if args.image_size < 1: + raise SystemExit("--image-size must be at least 1") + if args.samples < 1: + raise SystemExit("--samples must be at least 1") + + images = sorted( + path + for path in args.dataset_dir.rglob("*") + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS and path.parent.name == "images" + ) + if not images: + raise SystemExit(f"No images found under {args.dataset_dir}") + + args.calibration_dir.mkdir(parents=True, exist_ok=True) + args.input_file.parent.mkdir(parents=True, exist_ok=True) + for stale_sample in args.calibration_dir.glob("sample_*.npy"): + stale_sample.unlink() + + prepared: list[np.ndarray] = [] + for index, image_path in enumerate(images[: args.samples]): + sample = preprocess_image(image_path, args.image_size) + np.save(args.calibration_dir / f"sample_{index:03d}.npy", sample) + prepared.append(sample) + + np.savez(args.input_file, **{args.input_name: prepared[0]}) # pyright: ignore[reportArgumentType] + print(f"Wrote {len(prepared)} calibration samples to {args.calibration_dir}") + print(f"Wrote validation input to {args.input_file}") + + +if __name__ == "__main__": + main()