作者: lukewood,Ian Stenbit,Tirth Patel
创建日期 2023/04/08
最后修改日期 2023/08/10
描述:使用 KerasCV 训练目标检测模型。
KerasCV 提供了一套完整的生产级 API 来解决目标检测问题。这些 API 包括针对目标检测的特定数据增强技术、Keras 原生 COCO 指标、边界框格式转换实用程序、可视化工具、预训练目标检测模型,以及训练您自己的最先进目标检测模型所需的一切!
让我们试用一下 KerasCV 的目标检测 API。
!pip install -q --upgrade keras-cv
!pip install -q --upgrade keras # Upgrade to Keras 3.
import os
os.environ["KERAS_BACKEND"] = "jax" # @param ["tensorflow", "jax", "torch"]
from tensorflow import data as tf_data
import tensorflow_datasets as tfds
import keras
import keras_cv
import numpy as np
from keras_cv import bounding_box
import os
from keras_cv import visualization
import tqdm
目标检测是在给定图像中识别、分类和定位对象的過程。通常,您的输入是图像,您的标签是边界框,可以选择包含类别标签。目标检测可以被认为是分类的扩展,但是您需要检测和定位任意数量的类别,而不是图像的单个类别标签。
例如
上面图像的数据可能看起来像这样
image = [height, width, 3]
bounding_boxes = {
"classes": [0], # 0 is an arbitrary class ID representing "cat"
"boxes": [[0.25, 0.4, .15, .1]]
# bounding box is in "rel_xywh" format
# so 0.25 represents the start of the bounding box 25% of
# the way across the image.
# The .15 represents that the width is 15% of the image width.
}
自从 你只看一次(也称为 YOLO)问世以来,目标检测主要使用深度学习来解决。大多数深度学习架构通过巧妙地将目标检测问题描述为许多小型分类问题和许多回归问题的组合来实现这一点。
更具体地说,这是通过在输入图像上生成许多不同形状和大小的锚框,并为每个锚框分配类别标签以及 x
、y
、width
和 height
偏移量来实现的。模型经过训练来预测每个框的类别标签,以及预测为对象的每个框的 x
、y
、width
和 height
偏移量。
一些示例锚框的可视化:
目标检测是一个技术上复杂的难题,但幸运的是,我们提供了一种万无一失的方法来获得出色的结果。让我们开始吧!
KerasCV 目标检测 API 中最高级别的 API 是 keras_cv.models
API。此 API 包含完全预训练的目标检测模型,例如 keras_cv.models.YOLOV8Detector
。
让我们从构建一个在 pascalvoc
数据集上预训练的 YOLOV8Detector 开始。
pretrained_model = keras_cv.models.YOLOV8Detector.from_preset(
"yolo_v8_m_pascalvoc", bounding_box_format="xywh"
)
注意 bounding_box_format
参数吗?
回忆一下上面部分中边界框的格式
bounding_boxes = {
"classes": [num_boxes],
"boxes": [num_boxes, 4]
}
此参数精确地描述了您的管道中标签字典的 "boxes"
字段中的值采用什么格式。例如,以 xywh
格式的框,其左上角位于坐标 (100, 100),宽度为 55,高度为 70,将由
[100, 100, 55, 75]
或等效地,以 xyxy
格式表示
[100, 100, 155, 175]
虽然这看起来很简单,但它是 KerasCV 目标检测 API 的关键部分!处理边界框的每个组件都需要一个 bounding_box_format
参数。您可以阅读更多关于 KerasCV 边界框格式的信息 在 API 文档中。
这样做是因为边界框没有一个正确的格式!不同管道中的组件需要不同的格式,因此通过要求指定这些格式,我们确保我们的组件保持可读、可重用和清晰。边界框格式转换错误可能是目标检测管道中最常见的错误,通过要求此参数,我们减轻了这些错误(尤其是在将来自多个来源的代码组合在一起时)。
接下来,让我们加载一个图像
filepath = keras.utils.get_file(origin="https://i.imgur.com/gCNcJJI.jpg")
image = keras.utils.load_img(filepath)
image = np.array(image)
visualization.plot_image_gallery(
np.array([image]),
value_range=(0, 255),
rows=1,
cols=1,
scale=5,
)
要将 YOLOV8Detector
架构与 ResNet50 主干一起使用,您需要将您的图像调整到可以被 64 整除的大小。这是为了确保与 ResNet 中卷积层执行的下采样操作数量兼容。
如果调整大小操作扭曲了输入的长宽比,模型的性能将明显下降。对于我们使用的预训练 "yolo_v8_m_pascalvoc"
预设,在使用简单的调整大小操作时,pascalvoc/2012
评估集上的最终 MeanAveragePrecision
会从 0.38
下降到 0.15
。
此外,如果您像在分类中那样裁剪以保持长宽比,您的模型可能会完全错过一些边界框。因此,在对目标检测模型运行推断时,我们建议使用填充到所需大小,同时调整最长边以匹配长宽比。
KerasCV 使正确调整大小变得容易;只需将 pad_to_aspect_ratio=True
传递给 keras_cv.layers.Resizing
层即可。
这可以在一行代码中实现
inference_resizing = keras_cv.layers.Resizing(
640, 640, pad_to_aspect_ratio=True, bounding_box_format="xywh"
)
这可以作为我们的推断预处理管道
image_batch = inference_resizing([image])
keras_cv.visualization.plot_bounding_box_gallery()
支持 class_mapping
参数来突出显示每个框被分配到的类别。现在让我们组装一个类别映射。
class_ids = [
"Aeroplane",
"Bicycle",
"Bird",
"Boat",
"Bottle",
"Bus",
"Car",
"Cat",
"Chair",
"Cow",
"Dining Table",
"Dog",
"Horse",
"Motorbike",
"Person",
"Potted Plant",
"Sheep",
"Sofa",
"Train",
"Tvmonitor",
"Total",
]
class_mapping = dict(zip(range(len(class_ids)), class_ids))
就像任何其他 keras.Model
一样,您可以使用 model.predict()
API 来预测边界框。
y_pred = pretrained_model.predict(image_batch)
# y_pred is a bounding box Tensor:
# {"classes": ..., boxes": ...}
visualization.plot_bounding_box_gallery(
image_batch,
value_range=(0, 255),
rows=1,
cols=1,
y_pred=y_pred,
scale=5,
font_scale=0.7,
bounding_box_format="xywh",
class_mapping=class_mapping,
)
1/1 ━━━━━━━━━━━━━━━━━━━━ 11s 11s/step
为了支持这种简单直观的推断工作流程,KerasCV 在 YOLOV8Detector
类中执行非最大抑制。非最大抑制是一种传统的计算算法,用于解决模型对同一个对象检测到多个框的问题。
非最大抑制是一种高度可配置的算法,在大多数情况下,您需要自定义模型的非最大抑制操作的设置。这可以通过覆盖 prediction_decoder
参数来完成。
为了展示这一概念,让我们暂时在我们的 YOLOV8Detector 上禁用非最大抑制。这可以通过写入 prediction_decoder
属性来完成。
# The following NonMaxSuppression layer is equivalent to disabling the operation
prediction_decoder = keras_cv.layers.NonMaxSuppression(
bounding_box_format="xywh",
from_logits=True,
iou_threshold=1.0,
confidence_threshold=0.0,
)
pretrained_model = keras_cv.models.YOLOV8Detector.from_preset(
"yolo_v8_m_pascalvoc",
bounding_box_format="xywh",
prediction_decoder=prediction_decoder,
)
y_pred = pretrained_model.predict(image_batch)
visualization.plot_bounding_box_gallery(
image_batch,
value_range=(0, 255),
rows=1,
cols=1,
y_pred=y_pred,
scale=5,
font_scale=0.7,
bounding_box_format="xywh",
class_mapping=class_mapping,
)
1/1 ━━━━━━━━━━━━━━━━━━━━ 5s 5s/step
接下来,让我们重新配置 keras_cv.layers.NonMaxSuppression
以满足我们的用例!在本例中,我们将 iou_threshold
调整为 0.2
,并将 confidence_threshold
调整为 0.7
。
提高 confidence_threshold
将导致模型仅输出具有更高置信度得分的框。iou_threshold
控制两个框必须具有的交并比 (IoU) 的阈值,以便其中一个被剪枝。有关这些参数的更多信息可以在 TensorFlow API 文档中找到
prediction_decoder = keras_cv.layers.NonMaxSuppression(
bounding_box_format="xywh",
from_logits=True,
# Decrease the required threshold to make predictions get pruned out
iou_threshold=0.2,
# Tune confidence threshold for predictions to pass NMS
confidence_threshold=0.7,
)
pretrained_model = keras_cv.models.YOLOV8Detector.from_preset(
"yolo_v8_m_pascalvoc",
bounding_box_format="xywh",
prediction_decoder=prediction_decoder,
)
y_pred = pretrained_model.predict(image_batch)
visualization.plot_bounding_box_gallery(
image_batch,
value_range=(0, 255),
rows=1,
cols=1,
y_pred=y_pred,
scale=5,
font_scale=0.7,
bounding_box_format="xywh",
class_mapping=class_mapping,
)
1/1 ━━━━━━━━━━━━━━━━━━━━ 5s 5s/step
看起来好多了!
无论您是目标检测新手还是经验丰富的专家,从头开始组装目标检测管道都是一项巨大的工作。幸运的是,所有 KerasCV 目标检测 API 都是作为模块化组件构建的。无论您需要完整的管道、仅仅是目标检测模型,还是仅仅是将框从 xywh
格式转换为 xyxy
的转换实用程序,KerasCV 都能满足您的需求。
在本指南中,我们将为 KerasCV 目标检测模型组装一个完整的训练管道。这包括数据加载、增强、指标评估和推断!
首先,让我们整理好所有导入并定义全局配置参数。
BATCH_SIZE = 4
首先,让我们讨论数据加载和边界框格式。KerasCV 具有一个预定义的边界框格式。为了符合此格式,您应该将您的边界框打包到一个字典中,该字典与以下规范匹配
bounding_boxes = {
# num_boxes may be a Ragged dimension
'boxes': Tensor(shape=[batch, num_boxes, 4]),
'classes': Tensor(shape=[batch, num_boxes])
}
bounding_boxes['boxes']
包含边界框的坐标,采用 KerasCV 支持的 bounding_box_format
。KerasCV 在处理边界框的所有组件中都需要一个 bounding_box_format
参数。这样做是为了最大限度地提高您将各个组件插入其目标检测管道的能力,以及使代码在目标检测管道中自文档化。
为了匹配 KerasCV API 风格,建议在编写自定义数据加载器时,也支持 bounding_box_format
参数。这使得调用您数据加载器的人员清楚地知道边界框采用什么格式。在本例中,我们将边界框格式化为 xywh
格式。
例如
train_ds, ds_info = your_data_loader.load(
split='train', bounding_box_format='xywh', batch_size=8
)
这显然会生成xywh
格式的边界框。您可以阅读有关 KerasCV 边界框格式的更多信息 在 API 文档中。
我们的数据以{"images": images, "bounding_boxes": bounding_boxes}
格式加载。此格式受所有 KerasCV 预处理组件支持。
让我们加载一些数据并验证数据是否符合我们的预期。
def visualize_dataset(inputs, value_range, rows, cols, bounding_box_format):
inputs = next(iter(inputs.take(1)))
images, bounding_boxes = inputs["images"], inputs["bounding_boxes"]
visualization.plot_bounding_box_gallery(
images,
value_range=value_range,
rows=rows,
cols=cols,
y_true=bounding_boxes,
scale=5,
font_scale=0.7,
bounding_box_format=bounding_box_format,
class_mapping=class_mapping,
)
def unpackage_raw_tfds_inputs(inputs, bounding_box_format):
image = inputs["image"]
boxes = keras_cv.bounding_box.convert_format(
inputs["objects"]["bbox"],
images=image,
source="rel_yxyx",
target=bounding_box_format,
)
bounding_boxes = {
"classes": inputs["objects"]["label"],
"boxes": boxes,
}
return {"images": image, "bounding_boxes": bounding_boxes}
def load_pascal_voc(split, dataset, bounding_box_format):
ds = tfds.load(dataset, split=split, with_info=False, shuffle_files=True)
ds = ds.map(
lambda x: unpackage_raw_tfds_inputs(x, bounding_box_format=bounding_box_format),
num_parallel_calls=tf_data.AUTOTUNE,
)
return ds
train_ds = load_pascal_voc(
split="train", dataset="voc/2007", bounding_box_format="xywh"
)
eval_ds = load_pascal_voc(split="test", dataset="voc/2007", bounding_box_format="xywh")
train_ds = train_ds.shuffle(BATCH_SIZE * 4)
接下来,让我们对数据进行批处理。
在 KerasCV 目标检测任务中,建议用户使用不规则批次的输入。这是因为图像在 PascalVOC 中可能大小不同,并且每张图像可能包含不同数量的边界框。
要在 tf.data
管道中构建不规则数据集,可以使用ragged_batch()
方法。
train_ds = train_ds.ragged_batch(BATCH_SIZE, drop_remainder=True)
eval_ds = eval_ds.ragged_batch(BATCH_SIZE, drop_remainder=True)
让我们确保我们的数据集符合 KerasCV 的预期格式。通过使用visualize_dataset()
函数,您可以直观地验证数据是否符合 KerasCV 的预期格式。如果边界框不可见或在错误的位置可见,则表明您的数据格式错误。
visualize_dataset(
train_ds, bounding_box_format="xywh", value_range=(0, 255), rows=2, cols=2
)
以及评估集
visualize_dataset(
eval_ds,
bounding_box_format="xywh",
value_range=(0, 255),
rows=2,
cols=2,
# If you are not running your experiment on a local machine, you can also
# make `visualize_dataset()` dump the plot to a file using `path`:
# path="eval.png"
)
看起来一切结构都按预期进行。现在我们可以继续构建我们的数据增强管道。
在构建目标检测管道时,最具挑战性的任务之一是数据增强。图像增强技术必须了解底层边界框,并且必须相应地更新它们。
幸运的是,KerasCV 通过其广泛的 数据增强层 库本机支持边界框增强。下面的代码加载 Pascal VOC 数据集,并在 tf.data
管道内执行实时、边界框友好的数据增强。
augmenters = [
keras_cv.layers.RandomFlip(mode="horizontal", bounding_box_format="xywh"),
keras_cv.layers.JitteredResize(
target_size=(640, 640), scale_factor=(0.75, 1.3), bounding_box_format="xywh"
),
]
def create_augmenter_fn(augmenters):
def augmenter_fn(inputs):
for augmenter in augmenters:
inputs = augmenter(inputs)
return inputs
return augmenter_fn
augmenter_fn = create_augmenter_fn(augmenters)
train_ds = train_ds.map(augmenter_fn, num_parallel_calls=tf_data.AUTOTUNE)
visualize_dataset(
train_ds, bounding_box_format="xywh", value_range=(0, 255), rows=2, cols=2
)
太好了!我们现在有了边界框友好的数据增强管道。让我们将我们的评估数据集格式化为匹配。不要使用JitteredResize
,而是使用确定性keras_cv.layers.Resizing()
层。
inference_resizing = keras_cv.layers.Resizing(
640, 640, bounding_box_format="xywh", pad_to_aspect_ratio=True
)
eval_ds = eval_ds.map(inference_resizing, num_parallel_calls=tf_data.AUTOTUNE)
由于调整大小操作在使用JitteredResize()
调整大小图像的训练数据集和使用layers.Resizing(pad_to_aspect_ratio=True)
的推理数据集之间存在差异,因此建议可视化两个数据集。
visualize_dataset(
eval_ds, bounding_box_format="xywh", value_range=(0, 255), rows=2, cols=2
)
最后,让我们从预处理字典中解包输入,并准备将输入馈送到我们的模型中。为了与 TPU 兼容,边界框张量需要是Dense
而不是Ragged
。
def dict_to_tuple(inputs):
return inputs["images"], bounding_box.to_dense(
inputs["bounding_boxes"], max_boxes=32
)
train_ds = train_ds.map(dict_to_tuple, num_parallel_calls=tf_data.AUTOTUNE)
eval_ds = eval_ds.map(dict_to_tuple, num_parallel_calls=tf_data.AUTOTUNE)
train_ds = train_ds.prefetch(tf_data.AUTOTUNE)
eval_ds = eval_ds.prefetch(tf_data.AUTOTUNE)
在本指南中,我们使用标准 SGD 优化器并依赖 [keras.callbacks.ReduceLROnPlateau
](/api/callbacks/reduce_lr_on_plateau#reducelronplateau-class) 回调来降低学习率。
在训练目标检测模型时,您始终希望包含global_clipnorm
。这样做是为了解决在训练目标检测模型时经常出现的梯度爆炸问题。
base_lr = 0.005
# including a global_clipnorm is extremely important in object detection tasks
optimizer = keras.optimizers.SGD(
learning_rate=base_lr, momentum=0.9, global_clipnorm=10.0
)
为了在您的数据集上获得最佳结果,您可能需要手工制作PiecewiseConstantDecay
学习率计划。虽然PiecewiseConstantDecay
计划往往表现更好,但它们不会在问题之间进行转换。
您可能不熟悉"ciou"
损失。虽然在其他模型中并不常见,但这种损失有时用于目标检测领域。
简而言之,"Complete IoU" 是交并比损失的一种形式,由于其收敛特性而被使用。
在 KerasCV 中,您可以简单地将字符串"ciou"
传递给compile()
来使用此损失。我们还使用标准二元交叉熵损失来处理类别头。
pretrained_model.compile(
classification_loss="binary_crossentropy",
box_loss="ciou",
)
最受欢迎的目标检测指标是 COCO 指标,这些指标是与 MSCOCO 数据集一起发布的。KerasCV 在keras_cv.callbacks.PyCOCOCallback
符号下提供了一套易于使用的 COCO 指标。请注意,我们使用 Keras 回调而不是 Keras 指标来计算 COCO 指标。这是因为计算 COCO 指标需要在内存中一次存储模型对整个评估数据集的所有预测,这在训练时间是不切实际的。
coco_metrics_callback = keras_cv.callbacks.PyCOCOCallback(
eval_ds.take(20), bounding_box_format="xywh"
)
我们的数据管道现在已完成!现在我们可以继续创建和训练模型。
接下来,让我们使用 KerasCV API 来构建一个未经训练的 YOLOV8Detector 模型。在本教程中,我们使用来自 ImageNet 数据集的预训练 ResNet50 主干。
KerasCV 使得使用任何 KerasCV 主干构建YOLOV8Detector
变得容易。只需使用您想要的架构的预设之一即可!
例如
model = keras_cv.models.YOLOV8Detector.from_preset(
"resnet50_imagenet",
# For more info on supported bounding box formats, visit
# https://keras.org.cn/api/keras_cv/bounding_box/
bounding_box_format="xywh",
num_classes=20,
)
这就是构建 KerasCV YOLOv8 所需的一切。YOLOv8 接受稠密图像张量和边界框字典的元组,用于fit()
和train_on_batch()
这与我们在上面的输入管道中构建的内容相匹配。
剩下的就是训练我们的模型。KerasCV 目标检测模型遵循标准的 Keras 工作流程,利用compile()
和fit()
。
让我们编译我们的模型
model.compile(
classification_loss="binary_crossentropy",
box_loss="ciou",
optimizer=optimizer,
)
如果要完全训练模型,请从所有数据集引用中删除.take(20)
(下面以及指标回调的初始化中)。
model.fit(
train_ds.take(20),
# Run for 10-35~ epochs to achieve good scores.
epochs=1,
callbacks=[coco_metrics_callback],
)
20/20 ━━━━━━━━━━━━━━━━━━━━ 7s 59ms/step
creating index...
index created!
creating index...
index created!
Running per image evaluation...
Evaluate annotation type *bbox*
DONE (t=0.16s).
Accumulating evaluation results...
DONE (t=0.07s).
Average Precision (AP) @[ IoU=0.50:0.95 | area= all | maxDets=100 ] = 0.000
Average Precision (AP) @[ IoU=0.50 | area= all | maxDets=100 ] = 0.000
Average Precision (AP) @[ IoU=0.75 | area= all | maxDets=100 ] = 0.000
Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.000
Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.000
Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.000
Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets= 1 ] = 0.002
Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets= 10 ] = 0.002
Average Recall (AR) @[ IoU=0.50:0.95 | area= all | maxDets=100 ] = 0.002
Average Recall (AR) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.000
Average Recall (AR) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.000
Average Recall (AR) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.002
20/20 ━━━━━━━━━━━━━━━━━━━━ 73s 681ms/step - loss: 9221.7988 - val_AP: 3.1673e-05 - val_AP50: 2.1886e-04 - val_AP75: 0.0000e+00 - val_APs: 0.0000e+00 - val_APm: 0.0000e+00 - val_APl: 3.1673e-05 - val_ARmax1: 0.0016 - val_ARmax10: 0.0021 - val_ARmax100: 0.0021 - val_ARs: 0.0000e+00 - val_ARm: 0.0000e+00 - val_ARl: 0.0021
<keras.src.callbacks.history.History at 0x7fb23010a850>
KerasCV 使目标检测推理变得简单。model.predict(images)
返回边界框的张量。默认情况下,YOLOV8Detector.predict()
将为您执行非最大抑制操作。
在本节中,我们将使用keras_cv
提供的预设
model = keras_cv.models.YOLOV8Detector.from_preset(
"yolo_v8_m_pascalvoc", bounding_box_format="xywh"
)
接下来,为方便起见,我们使用更大的批次构建一个数据集
visualization_ds = eval_ds.unbatch()
visualization_ds = visualization_ds.ragged_batch(16)
visualization_ds = visualization_ds.shuffle(8)
让我们创建一个简单的函数来绘制我们的推断结果
def visualize_detections(model, dataset, bounding_box_format):
images, y_true = next(iter(dataset.take(1)))
y_pred = model.predict(images)
visualization.plot_bounding_box_gallery(
images,
value_range=(0, 255),
bounding_box_format=bounding_box_format,
y_true=y_true,
y_pred=y_pred,
scale=4,
rows=2,
cols=2,
show=True,
font_scale=0.7,
class_mapping=class_mapping,
)
您可能需要配置您的非最大抑制操作以获得视觉上令人愉悦的结果。
model.prediction_decoder = keras_cv.layers.NonMaxSuppression(
bounding_box_format="xywh",
from_logits=True,
iou_threshold=0.5,
confidence_threshold=0.75,
)
visualize_detections(model, dataset=visualization_ds, bounding_box_format="xywh")
1/1 ━━━━━━━━━━━━━━━━━━━━ 16s 16s/step
太棒了!要了解的最后一个有用的模式是在 keras.callbacks.Callback
中可视化检测,以监控训练
class VisualizeDetections(keras.callbacks.Callback):
def on_epoch_end(self, epoch, logs):
visualize_detections(
self.model, bounding_box_format="xywh", dataset=visualization_ds
)
KerasCV 使构建最先进的目标检测管道变得容易。在本指南中,我们首先使用 KerasCV 边界框规范编写数据加载器。在此之后,我们使用 KerasCV 预处理层在不到 50 行代码中组装了一个生产级数据增强管道。
KerasCV 目标检测组件可以独立使用,但也彼此深度集成。KerasCV 使得编写生产级边界框增强、模型训练、可视化和指标评估变得容易。
一些供读者参考的后续练习
最后一个有趣的代码片段,展示 KerasCV API 的强大功能!
stable_diffusion = keras_cv.models.StableDiffusionV2(512, 512)
images = stable_diffusion.text_to_image(
prompt="A zoomed out photograph of a cool looking cat. The cat stands in a beautiful forest",
negative_prompt="unrealistic, bad looking, malformed",
batch_size=4,
seed=1231,
)
encoded_predictions = model(images)
y_pred = model.decode_predictions(encoded_predictions, images)
visualization.plot_bounding_box_gallery(
images,
value_range=(0, 255),
y_pred=y_pred,
rows=2,
cols=2,
scale=5,
font_scale=0.7,
bounding_box_format="xywh",
class_mapping=class_mapping,
)
By using this model checkpoint, you acknowledge that its usage is subject to the terms of the CreativeML Open RAIL++-M license at https://github.com/Stability-AI/stablediffusion/blob/main/LICENSE-MODEL
50/50 ━━━━━━━━━━━━━━━━━━━━ 47s 356ms/step