0bb4ee8941
PiperOrigin-RevId: 544393692
365 lines
13 KiB
Python
365 lines
13 KiB
Python
# Copyright 2023 The MediaPipe Authors.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Custom Model for Object Detection."""
|
|
|
|
import os
|
|
from typing import Mapping, Optional, Sequence, Union
|
|
|
|
import tensorflow as tf
|
|
|
|
from mediapipe.model_maker.python.vision.object_detector import detection
|
|
from mediapipe.model_maker.python.vision.object_detector import model_options as model_opt
|
|
from mediapipe.model_maker.python.vision.object_detector import model_spec as ms
|
|
from official.core import config_definitions as cfg
|
|
from official.projects.qat.vision.configs import common as qat_common
|
|
from official.projects.qat.vision.modeling import factory as qat_factory
|
|
from official.vision import configs
|
|
from official.vision.losses import focal_loss
|
|
from official.vision.losses import loss_utils
|
|
from official.vision.modeling import factory
|
|
from official.vision.modeling import retinanet_model
|
|
from official.vision.modeling.layers import detection_generator
|
|
|
|
|
|
class ObjectDetectorModel(tf.keras.Model):
|
|
"""An object detector model which can be trained using Model Maker's training API.
|
|
|
|
Attributes:
|
|
loss_trackers: List of tf.keras.metrics.Mean objects used to track the loss
|
|
during training.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
model_spec: ms.ModelSpec,
|
|
model_options: model_opt.ObjectDetectorModelOptions,
|
|
num_classes: int,
|
|
) -> None:
|
|
"""Initializes an ObjectDetectorModel.
|
|
|
|
Args:
|
|
model_spec: Specification for the model.
|
|
model_options: Model options for creating the model.
|
|
num_classes: Number of classes for object detection.
|
|
"""
|
|
super().__init__()
|
|
self._model_spec = model_spec
|
|
self._model_options = model_options
|
|
self._num_classes = num_classes
|
|
self._model = self._build_model()
|
|
checkpoint_folder = self._model_spec.downloaded_files.get_path()
|
|
checkpoint_file = os.path.join(
|
|
checkpoint_folder, self._model_spec.checkpoint_name
|
|
)
|
|
self.load_checkpoint(checkpoint_file)
|
|
self._model.summary()
|
|
self.loss_trackers = [
|
|
tf.keras.metrics.Mean(name=n)
|
|
for n in ['total_loss', 'cls_loss', 'box_loss', 'model_loss']
|
|
]
|
|
|
|
def _get_model_config(
|
|
self,
|
|
generator_config: configs.retinanet.DetectionGenerator = configs.retinanet.DetectionGenerator(),
|
|
) -> configs.retinanet.RetinaNet:
|
|
model_config = configs.retinanet.RetinaNet(
|
|
min_level=self._model_spec.min_level,
|
|
max_level=self._model_spec.max_level,
|
|
num_classes=self._num_classes,
|
|
input_size=self._model_spec.input_image_shape,
|
|
anchor=configs.retinanet.Anchor(
|
|
num_scales=3, aspect_ratios=[0.5, 1.0, 2.0], anchor_size=3
|
|
),
|
|
backbone=configs.backbones.Backbone(
|
|
type='mobilenet',
|
|
mobilenet=configs.backbones.MobileNet(
|
|
model_id=self._model_spec.model_id
|
|
),
|
|
),
|
|
decoder=configs.decoders.Decoder(
|
|
type='fpn',
|
|
fpn=configs.decoders.FPN(
|
|
num_filters=128, use_separable_conv=True, use_keras_layer=True
|
|
),
|
|
),
|
|
head=configs.retinanet.RetinaNetHead(
|
|
num_filters=128, use_separable_conv=True
|
|
),
|
|
detection_generator=generator_config,
|
|
norm_activation=configs.common.NormActivation(activation='relu6'),
|
|
)
|
|
return model_config
|
|
|
|
def _build_model(self, omit_l2=False) -> tf.keras.Model:
|
|
"""Builds a RetinaNet object detector model."""
|
|
input_specs = tf.keras.layers.InputSpec(
|
|
shape=[None] + self._model_spec.input_image_shape
|
|
)
|
|
if omit_l2:
|
|
l2_regularizer = None
|
|
else:
|
|
l2_regularizer = tf.keras.regularizers.l2(
|
|
self._model_options.l2_weight_decay / 2.0
|
|
)
|
|
model_config = self._get_model_config()
|
|
|
|
return factory.build_retinanet(input_specs, model_config, l2_regularizer)
|
|
|
|
def save_checkpoint(self, checkpoint_path: str) -> None:
|
|
"""Saves a model checkpoint to checkpoint_path.
|
|
|
|
Args:
|
|
checkpoint_path: The path to save checkpoint.
|
|
"""
|
|
ckpt_items = {
|
|
'backbone': self._model.backbone,
|
|
'decoder': self._model.decoder,
|
|
'head': self._model.head,
|
|
}
|
|
tf.train.Checkpoint(**ckpt_items).write(checkpoint_path)
|
|
|
|
def load_checkpoint(
|
|
self, checkpoint_path: str, include_last_layer: bool = False
|
|
) -> None:
|
|
"""Loads a model checkpoint from checkpoint_path.
|
|
|
|
Args:
|
|
checkpoint_path: The path to load a checkpoint from.
|
|
include_last_layer: Whether or not to load the last classification layer.
|
|
The size of the last classification layer will differ depending on the
|
|
number of classes. When loading from the pre-trained checkpoint, this
|
|
parameter should be False to avoid shape mismatch on the last layer.
|
|
Defaults to False.
|
|
"""
|
|
dummy_input = tf.zeros([1] + self._model_spec.input_image_shape)
|
|
self._model(dummy_input, training=True)
|
|
if include_last_layer:
|
|
head = self._model.head
|
|
else:
|
|
head_classifier = tf.train.Checkpoint(
|
|
depthwise_kernel=self._model.head._classifier.depthwise_kernel # pylint:disable=protected-access
|
|
)
|
|
head_items = {
|
|
'_classifier': head_classifier,
|
|
'_box_norms': self._model.head._box_norms, # pylint:disable=protected-access
|
|
'_box_regressor': self._model.head._box_regressor, # pylint:disable=protected-access
|
|
'_cls_convs': self._model.head._cls_convs, # pylint:disable=protected-access
|
|
'_cls_norms': self._model.head._cls_norms, # pylint:disable=protected-access
|
|
'_box_convs': self._model.head._box_convs, # pylint:disable=protected-access
|
|
}
|
|
head = tf.train.Checkpoint(**head_items)
|
|
ckpt_items = {
|
|
'backbone': self._model.backbone,
|
|
'decoder': self._model.decoder,
|
|
'head': head,
|
|
}
|
|
ckpt = tf.train.Checkpoint(**ckpt_items)
|
|
status = ckpt.read(checkpoint_path)
|
|
status.expect_partial().assert_existing_objects_matched()
|
|
|
|
def convert_to_qat(self) -> None:
|
|
"""Converts the model to a QAT RetinaNet model."""
|
|
model = self._build_model(omit_l2=True)
|
|
dummy_input = tf.zeros([1] + self._model_spec.input_image_shape)
|
|
model(dummy_input, training=True)
|
|
model.set_weights(self._model.get_weights())
|
|
quantization_config = qat_common.Quantization(
|
|
quantize_detection_decoder=True, quantize_detection_head=True
|
|
)
|
|
model_config = self._get_model_config()
|
|
qat_model = qat_factory.build_qat_retinanet(
|
|
model, quantization_config, model_config
|
|
)
|
|
self._model = qat_model
|
|
|
|
def export_saved_model(self, save_path: str):
|
|
"""Exports a saved_model for tflite conversion.
|
|
|
|
The export process modifies the model in the following two ways:
|
|
1. Replaces the nms operation in the detection generator with a custom
|
|
TFLite compatible nms operation.
|
|
2. Wraps the model with a DetectionModule which handles pre-processing
|
|
and post-processing when running inference.
|
|
|
|
Args:
|
|
save_path: Path to export the saved model.
|
|
"""
|
|
generator_config = configs.retinanet.DetectionGenerator(
|
|
nms_version='tflite',
|
|
tflite_post_processing=configs.common.TFLitePostProcessingConfig(
|
|
nms_score_threshold=0,
|
|
max_detections=10,
|
|
max_classes_per_detection=1,
|
|
normalize_anchor_coordinates=True,
|
|
omit_nms=True,
|
|
),
|
|
)
|
|
tflite_post_processing_config = (
|
|
generator_config.tflite_post_processing.as_dict()
|
|
)
|
|
tflite_post_processing_config['input_image_size'] = (
|
|
self._model_spec.input_image_shape[0],
|
|
self._model_spec.input_image_shape[1],
|
|
)
|
|
detection_generator_obj = detection_generator.MultilevelDetectionGenerator(
|
|
apply_nms=generator_config.apply_nms,
|
|
pre_nms_top_k=generator_config.pre_nms_top_k,
|
|
pre_nms_score_threshold=generator_config.pre_nms_score_threshold,
|
|
nms_iou_threshold=generator_config.nms_iou_threshold,
|
|
max_num_detections=generator_config.max_num_detections,
|
|
nms_version=generator_config.nms_version,
|
|
use_cpu_nms=generator_config.use_cpu_nms,
|
|
soft_nms_sigma=generator_config.soft_nms_sigma,
|
|
tflite_post_processing_config=tflite_post_processing_config,
|
|
return_decoded=generator_config.return_decoded,
|
|
use_class_agnostic_nms=generator_config.use_class_agnostic_nms,
|
|
)
|
|
model_config = self._get_model_config(generator_config)
|
|
model = retinanet_model.RetinaNetModel(
|
|
self._model.backbone,
|
|
self._model.decoder,
|
|
self._model.head,
|
|
detection_generator_obj,
|
|
min_level=model_config.min_level,
|
|
max_level=model_config.max_level,
|
|
num_scales=model_config.anchor.num_scales,
|
|
aspect_ratios=model_config.anchor.aspect_ratios,
|
|
anchor_size=model_config.anchor.anchor_size,
|
|
)
|
|
task_config = configs.retinanet.RetinaNetTask(model=model_config)
|
|
params = cfg.ExperimentConfig(
|
|
task=task_config,
|
|
)
|
|
export_module = detection.DetectionModule(
|
|
params=params,
|
|
batch_size=1,
|
|
input_image_size=self._model_spec.input_image_shape[:2],
|
|
input_type='tflite',
|
|
num_channels=self._model_spec.input_image_shape[2],
|
|
model=model,
|
|
)
|
|
function_keys = {'tflite': tf.saved_model.DEFAULT_SERVING_SIGNATURE_DEF_KEY}
|
|
signatures = export_module.get_inference_signatures(function_keys)
|
|
|
|
tf.saved_model.save(export_module, save_path, signatures=signatures)
|
|
|
|
# The remaining method overrides are used to train this object detector model
|
|
# using model.fit().
|
|
def call(
|
|
self,
|
|
images: Union[tf.Tensor, Sequence[tf.Tensor]],
|
|
image_shape: Optional[tf.Tensor] = None,
|
|
anchor_boxes: Optional[Mapping[str, tf.Tensor]] = None,
|
|
output_intermediate_features: bool = False,
|
|
training: bool = None,
|
|
) -> Mapping[str, tf.Tensor]:
|
|
"""Overrides call from tf.keras.Model."""
|
|
return self._model(
|
|
images,
|
|
image_shape,
|
|
anchor_boxes,
|
|
output_intermediate_features,
|
|
training,
|
|
)
|
|
|
|
def compute_loss(self, x=None, y=None, y_pred=None, sample_weight=None):
|
|
"""Overrides compute_loss from tf.keras.Model."""
|
|
cls_loss_fn = focal_loss.FocalLoss(
|
|
alpha=0.25, gamma=1.5, reduction=tf.keras.losses.Reduction.SUM
|
|
)
|
|
box_loss_fn = tf.keras.losses.Huber(
|
|
0.1, reduction=tf.keras.losses.Reduction.SUM
|
|
)
|
|
labels = y
|
|
outputs = y_pred
|
|
# Sums all positives in a batch for normalization and avoids zero
|
|
# num_positives_sum, which would lead to inf loss during training
|
|
cls_sample_weight = labels['cls_weights']
|
|
box_sample_weight = labels['box_weights']
|
|
num_positives = tf.reduce_sum(box_sample_weight) + 1.0
|
|
cls_sample_weight = cls_sample_weight / num_positives
|
|
box_sample_weight = box_sample_weight / num_positives
|
|
y_true_cls = loss_utils.multi_level_flatten(
|
|
labels['cls_targets'], last_dim=None
|
|
)
|
|
y_true_cls = tf.one_hot(y_true_cls, self._num_classes)
|
|
y_pred_cls = loss_utils.multi_level_flatten(
|
|
outputs['cls_outputs'], last_dim=self._num_classes
|
|
)
|
|
y_true_box = loss_utils.multi_level_flatten(
|
|
labels['box_targets'], last_dim=4
|
|
)
|
|
y_pred_box = loss_utils.multi_level_flatten(
|
|
outputs['box_outputs'], last_dim=4
|
|
)
|
|
|
|
cls_loss = cls_loss_fn(
|
|
y_true=y_true_cls, y_pred=y_pred_cls, sample_weight=cls_sample_weight
|
|
)
|
|
box_loss = box_loss_fn(
|
|
y_true=y_true_box, y_pred=y_pred_box, sample_weight=box_sample_weight
|
|
)
|
|
|
|
model_loss = cls_loss + 50 * box_loss
|
|
total_loss = model_loss
|
|
regularization_losses = self._model.losses
|
|
if regularization_losses:
|
|
reg_loss = tf.reduce_sum(regularization_losses)
|
|
total_loss = model_loss + reg_loss
|
|
all_losses = {
|
|
'total_loss': total_loss,
|
|
'cls_loss': cls_loss,
|
|
'box_loss': box_loss,
|
|
'model_loss': model_loss,
|
|
}
|
|
for m in self.metrics:
|
|
m.update_state(all_losses[m.name])
|
|
return total_loss
|
|
|
|
@property
|
|
def metrics(self):
|
|
"""Overrides metrics from tf.keras.Model."""
|
|
return self.loss_trackers
|
|
|
|
def compute_metrics(self, x, y, y_pred, sample_weight=None):
|
|
"""Overrides compute_metrics from tf.keras.Model."""
|
|
return self.get_metrics_result()
|
|
|
|
def train_step(self, data):
|
|
"""Overrides train_step from tf.keras.Model."""
|
|
tf.keras.backend.set_learning_phase(1)
|
|
x, y = data
|
|
# Run forward pass.
|
|
with tf.GradientTape() as tape:
|
|
y_pred = self(x, training=True)
|
|
loss = self.compute_loss(x, y, y_pred)
|
|
self._validate_target_and_loss(y, loss)
|
|
# Run backwards pass.
|
|
self.optimizer.minimize(loss, self.trainable_variables, tape=tape)
|
|
return self.compute_metrics(x, y, y_pred)
|
|
|
|
def test_step(self, data):
|
|
"""Overrides test_step from tf.keras.Model."""
|
|
tf.keras.backend.set_learning_phase(0)
|
|
x, y = data
|
|
y_pred = self(
|
|
x,
|
|
anchor_boxes=y['anchor_boxes'],
|
|
image_shape=y['image_info'][:, 1, :],
|
|
training=False,
|
|
)
|
|
# Updates stateful loss metrics.
|
|
self.compute_loss(x, y, y_pred)
|
|
return self.compute_metrics(x, y, y_pred)
|