| """ |
| Utilities for training a FOMO-style (Faster Objects, More Objects) object |
| detection model on a custom dataset exported from Roboflow in COCO JSON |
| format, and quantizing it to int8 TFLite for deployment on a |
| Seeed XIAO ESP32S3 Sense. |
| |
| FOMO reframes detection as a per-grid-cell classification problem: instead |
| of predicting bounding boxes, the model predicts an object class (or |
| "background") for each cell of a coarse output grid (stride 8 relative to |
| the input). This is dramatically cheaper than YOLO/SSD-style detection and |
| is the standard approach used for MCU-class hardware like the ESP32S3. |
| """ |
|
|
| import os |
| import json |
| import zipfile |
| import glob |
|
|
| import numpy as np |
| import tensorflow as tf |
| from PIL import Image, ImageDraw |
|
|
|
|
| |
| |
| |
|
|
| def extract_zip(zip_path, dest_dir): |
| os.makedirs(dest_dir, exist_ok=True) |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| zf.extractall(dest_dir) |
| return dest_dir |
|
|
|
|
| def find_coco_json(root_dir): |
| candidates = glob.glob(os.path.join(root_dir, "**", "*.json"), recursive=True) |
| for path in candidates: |
| try: |
| with open(path, "r") as f: |
| data = json.load(f) |
| if "images" in data and "annotations" in data and "categories" in data: |
| return path, data |
| except (json.JSONDecodeError, UnicodeDecodeError): |
| continue |
| raise FileNotFoundError( |
| f"No COCO-format _annotations.coco.json found under {root_dir}. " |
| "Zip the split folder exactly as Roboflow exported it " |
| "(images + _annotations.coco.json together, no extra nesting)." |
| ) |
|
|
|
|
| def load_coco_split(zip_path, work_dir, split_name): |
| """Extract a Roboflow COCO zip and return (samples, category_id_to_name).""" |
| dest = os.path.join(work_dir, split_name) |
| extract_zip(zip_path, dest) |
| json_path, coco = find_coco_json(dest) |
| images_dir = os.path.dirname(json_path) |
|
|
| id_to_file = {img["id"]: img["file_name"] for img in coco["images"]} |
| categories = {c["id"]: c["name"] for c in coco["categories"]} |
|
|
| anns_by_image = {} |
| for ann in coco["annotations"]: |
| anns_by_image.setdefault(ann["image_id"], []).append(ann) |
|
|
| samples = [] |
| for img_id, file_name in id_to_file.items(): |
| img_path = os.path.join(images_dir, file_name) |
| if not os.path.exists(img_path): |
| alt = os.path.join(images_dir, "images", file_name) |
| img_path = alt if os.path.exists(alt) else img_path |
| samples.append((img_path, anns_by_image.get(img_id, []))) |
|
|
| return samples, categories |
|
|
|
|
| def load_coco_split_from_files(file_objs, split_name): |
| """Build a split from a loose list of uploaded files (a folder upload |
| containing the images plus one _annotations.coco.json), rather than a |
| zip. Matches images to annotation entries by basename, since browser |
| directory uploads don't always preserve relative folder paths. |
| """ |
| if not file_objs: |
| raise FileNotFoundError( |
| f"No files were uploaded for the '{split_name}' split. " |
| "Select the folder containing your images and _annotations.coco.json." |
| ) |
|
|
| json_data = None |
| image_paths = {} |
| for f in file_objs: |
| path = f.name if hasattr(f, "name") else f |
| base = os.path.basename(path) |
| if base.lower().endswith(".json"): |
| try: |
| with open(path, "r") as fh: |
| candidate = json.load(fh) |
| if all(k in candidate for k in ("images", "annotations", "categories")): |
| json_data = candidate |
| except (json.JSONDecodeError, UnicodeDecodeError): |
| continue |
| else: |
| image_paths[base] = path |
|
|
| if json_data is None: |
| raise FileNotFoundError( |
| f"No COCO-format _annotations.coco.json found among the uploaded " |
| f"'{split_name}' files. Make sure it's included in the folder you selected." |
| ) |
|
|
| id_to_file = {img["id"]: img["file_name"] for img in json_data["images"]} |
| categories = {c["id"]: c["name"] for c in json_data["categories"]} |
|
|
| anns_by_image = {} |
| for ann in json_data["annotations"]: |
| anns_by_image.setdefault(ann["image_id"], []).append(ann) |
|
|
| def normalize(name): |
| stem = os.path.splitext(name)[0] |
| return "".join(ch for ch in stem.lower() if ch.isalnum()) |
|
|
| normalized_lookup = {normalize(base): path for base, path in image_paths.items()} |
|
|
| samples = [] |
| missing = 0 |
| for img_id, file_name in id_to_file.items(): |
| base = os.path.basename(file_name) |
| img_path = image_paths.get(base) |
| if img_path is None: |
| |
| |
| img_path = normalized_lookup.get(normalize(base)) |
| if img_path is None: |
| missing += 1 |
| continue |
| samples.append((img_path, anns_by_image.get(img_id, []))) |
|
|
| if not samples: |
| raise FileNotFoundError( |
| f"The '{split_name}' JSON references images, but none of the uploaded " |
| "files matched them by name. Double check every image is included." |
| ) |
|
|
| return samples, categories, missing |
|
|
|
|
| def build_label_map(train_categories, test_categories): |
| names = sorted(set(train_categories.values()) | set(test_categories.values())) |
| names = [n for n in names if n.strip().lower() not in ("background", "objects", "")] |
| if not names: |
| return {} |
| return {name: i + 1 for i, name in enumerate(names)} |
|
|
|
|
| |
| |
| |
|
|
| def make_dataset(samples, label_map, input_size, grid_size, batch_size, augment, cat_id_to_name): |
| stride = input_size / grid_size |
|
|
| def gen(): |
| for img_path, anns in samples: |
| try: |
| img = Image.open(img_path).convert("RGB") |
| except Exception: |
| continue |
| orig_w, orig_h = img.size |
| img_resized = img.resize((input_size, input_size)) |
| arr = np.asarray(img_resized, dtype=np.float32) / 255.0 |
|
|
| label_grid = np.zeros((grid_size, grid_size), dtype=np.int32) |
| for ann in anns: |
| name = cat_id_to_name.get(ann["category_id"]) |
| cls_idx = label_map.get(name) |
| if cls_idx is None: |
| continue |
| x, y, w, h = ann["bbox"] |
| cx = (x + w / 2) / orig_w * input_size |
| cy = (y + h / 2) / orig_h * input_size |
| gx = min(int(cx // stride), grid_size - 1) |
| gy = min(int(cy // stride), grid_size - 1) |
| label_grid[gy, gx] = cls_idx |
|
|
| yield arr, label_grid |
|
|
| ds = tf.data.Dataset.from_generator( |
| gen, |
| output_signature=( |
| tf.TensorSpec(shape=(input_size, input_size, 3), dtype=tf.float32), |
| tf.TensorSpec(shape=(grid_size, grid_size), dtype=tf.int32), |
| ), |
| ) |
|
|
| if augment: |
| def aug(img, label): |
| if tf.random.uniform(()) > 0.5: |
| img = tf.image.flip_left_right(img) |
| label = tf.reverse(label, axis=[1]) |
| img = tf.image.random_brightness(img, 0.2) |
| img = tf.clip_by_value(img, 0.0, 1.0) |
| return img, label |
| ds = ds.map(aug, num_parallel_calls=tf.data.AUTOTUNE) |
|
|
| ds = ds.shuffle(256).repeat().batch(batch_size).prefetch(tf.data.AUTOTUNE) |
| return ds |
|
|
|
|
| |
| |
| |
|
|
| def build_fomo_model(input_size, num_classes, alpha=0.35): |
| """MobileNetV2 backbone cut at 1/8 resolution + a small conv head. |
| This mirrors the architecture commonly called 'FOMO'.""" |
| inputs = tf.keras.Input(shape=(input_size, input_size, 3)) |
| base = tf.keras.applications.MobileNetV2( |
| input_tensor=inputs, alpha=alpha, include_top=False, weights="imagenet" |
| ) |
| target_size = input_size // 8 |
| cut_layer = None |
| for layer in base.layers: |
| try: |
| shape = layer.output.shape |
| except AttributeError: |
| continue |
| if shape is None or len(shape) != 4: |
| continue |
| if shape[1] == target_size: |
| cut_layer = layer |
| if cut_layer is None: |
| raise ValueError(f"Could not find a layer with spatial size {target_size} in MobileNetV2.") |
|
|
| x = cut_layer.output |
| x = tf.keras.layers.Conv2D(32, 1, activation="relu", name="fomo_head_conv")(x) |
| outputs = tf.keras.layers.Conv2D( |
| num_classes + 1, 1, activation="softmax", name="fomo_output" |
| )(x) |
| return tf.keras.Model(inputs, outputs, name="fomo") |
|
|
|
|
| def get_optimizer(name, learning_rate): |
| name = (name or "adamw").lower() |
| if name == "adamw": |
| return tf.keras.optimizers.AdamW(learning_rate=learning_rate, weight_decay=1e-4) |
| if name == "sgd": |
| return tf.keras.optimizers.SGD(learning_rate=learning_rate, momentum=0.9) |
| return tf.keras.optimizers.Adam(learning_rate=learning_rate) |
|
|
|
|
| def weighted_sparse_ce(background_weight): |
| def loss_fn(y_true, y_pred): |
| y_true = tf.cast(y_true, tf.int32) |
| per_cell = tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred) |
| weights = tf.where(tf.equal(y_true, 0), background_weight, 1.0) |
| return tf.reduce_mean(per_cell * weights) |
| return loss_fn |
|
|
|
|
| |
| |
| |
|
|
| class LogCallback(tf.keras.callbacks.Callback): |
| """Appends per-epoch log lines to a list so the caller can stream them.""" |
|
|
| def __init__(self, log_lines): |
| super().__init__() |
| self.log_lines = log_lines |
|
|
| def on_epoch_end(self, epoch, logs=None): |
| logs = logs or {} |
| line = f"Epoch {epoch + 1}: " + ", ".join(f"{k}={v:.4f}" for k, v in logs.items()) |
| self.log_lines.append(line) |
|
|
|
|
| def train_model(model, train_ds, val_ds, epochs, learning_rate, background_weight, log_lines, |
| optimizer_name="adamw", steps_per_epoch=None, validation_steps=None): |
| model.compile( |
| optimizer=get_optimizer(optimizer_name, learning_rate), |
| loss=weighted_sparse_ce(background_weight), |
| metrics=["accuracy"], |
| ) |
| callback = LogCallback(log_lines) |
| history = model.fit( |
| train_ds, |
| validation_data=val_ds, |
| epochs=epochs, |
| steps_per_epoch=steps_per_epoch, |
| validation_steps=validation_steps, |
| callbacks=[callback], |
| verbose=0, |
| ) |
| return history |
|
|
|
|
| |
| |
| |
|
|
| def quantize_model(model, representative_samples, input_size): |
| def rep_dataset(): |
| for img_path, _ in representative_samples[:100]: |
| try: |
| img = Image.open(img_path).convert("RGB").resize((input_size, input_size)) |
| except Exception: |
| continue |
| arr = np.asarray(img, dtype=np.float32) / 255.0 |
| yield [arr[np.newaxis, ...]] |
|
|
| converter = tf.lite.TFLiteConverter.from_keras_model(model) |
| converter.optimizations = [tf.lite.Optimize.DEFAULT] |
| converter.representative_dataset = rep_dataset |
| converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] |
| converter.inference_input_type = tf.int8 |
| converter.inference_output_type = tf.int8 |
| return converter.convert() |
|
|
|
|
| def tflite_to_c_header(tflite_bytes, var_name="crow_fomo_model"): |
| lines = [ |
| "// Auto-generated TFLite model as a C array for ESP32 deployment", |
| f"#ifndef {var_name.upper()}_H", |
| f"#define {var_name.upper()}_H", |
| "", |
| f"alignas(8) const unsigned char {var_name}[] = {{", |
| ] |
| hex_bytes = [f"0x{b:02x}" for b in tflite_bytes] |
| for i in range(0, len(hex_bytes), 12): |
| lines.append(" " + ", ".join(hex_bytes[i:i + 12]) + ",") |
| lines.append("};") |
| lines.append(f"const unsigned int {var_name}_len = {len(tflite_bytes)};") |
| lines.append("") |
| lines.append("#endif") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| def run_tflite_inference(tflite_path, pil_image, label_names, threshold=0.5): |
| interpreter = tf.lite.Interpreter(model_path=tflite_path) |
| interpreter.allocate_tensors() |
| input_details = interpreter.get_input_details()[0] |
| output_details = interpreter.get_output_details()[0] |
|
|
| input_size = input_details["shape"][1] |
| grid_size = output_details["shape"][1] |
|
|
| orig_w, orig_h = pil_image.size |
| img_resized = pil_image.convert("RGB").resize((input_size, input_size)) |
| arr = np.asarray(img_resized, dtype=np.float32) / 255.0 |
|
|
| in_scale, in_zero = input_details["quantization"] |
| if in_scale > 0: |
| arr_q = (arr / in_scale + in_zero).astype(np.int8) |
| else: |
| arr_q = arr.astype(np.int8) |
| interpreter.set_tensor(input_details["index"], arr_q[np.newaxis, ...]) |
| interpreter.invoke() |
| out = interpreter.get_tensor(output_details["index"])[0] |
|
|
| out_scale, out_zero = output_details["quantization"] |
| if out_scale > 0: |
| out = (out.astype(np.float32) - out_zero) * out_scale |
|
|
| stride_x = orig_w / grid_size |
| stride_y = orig_h / grid_size |
|
|
| draw_img = pil_image.convert("RGB").copy() |
| draw = ImageDraw.Draw(draw_img) |
| detections = [] |
|
|
| for gy in range(grid_size): |
| for gx in range(grid_size): |
| cell = out[gy, gx] |
| cls_idx = int(np.argmax(cell)) |
| conf = float(cell[cls_idx]) |
| if cls_idx == 0 or conf < threshold: |
| continue |
| cx = (gx + 0.5) * stride_x |
| cy = (gy + 0.5) * stride_y |
| r = min(stride_x, stride_y) * 0.6 |
| draw.ellipse([cx - r, cy - r, cx + r, cy + r], outline=(255, 0, 0), width=3) |
| label_name = label_names[cls_idx - 1] if cls_idx - 1 < len(label_names) else str(cls_idx) |
| draw.text((cx + r, cy - r), f"{label_name} {conf:.2f}", fill=(255, 0, 0)) |
| detections.append({"class": label_name, "confidence": conf, "x": cx, "y": cy}) |
|
|
| return draw_img, detections |