# Copyright (c) OpenMMLab. All rights reserved. import random from numbers import Number from typing import List, Optional, Sequence, Tuple, Union import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from mmengine.dist import barrier, broadcast, get_dist_info from mmengine.logging import MessageHub from mmengine.model import BaseDataPreprocessor, ImgDataPreprocessor from mmengine.structures import PixelData from mmengine.utils import is_seq_of from torch import Tensor from mmdet.models.utils import unfold_wo_center from mmdet.models.utils.misc import samplelist_boxtype2tensor from mmdet.registry import MODELS from mmdet.structures import DetDataSample from mmdet.structures.mask import BitmapMasks from mmdet.utils import ConfigType try: import skimage except ImportError: skimage = None @MODELS.register_module() class DetDataPreprocessor(ImgDataPreprocessor): """Image pre-processor for detection tasks. Comparing with the :class:`mmengine.ImgDataPreprocessor`, 1. It supports batch augmentations. 2. It will additionally append batch_input_shape and pad_shape to data_samples considering the object detection task. It provides the data pre-processing as follows - Collate and move data to the target device. - Pad inputs to the maximum size of current batch with defined ``pad_value``. The padding size can be divisible by a defined ``pad_size_divisor`` - Stack inputs to batch_inputs. - Convert inputs from bgr to rgb if the shape of input is (3, H, W). - Normalize image with defined std and mean. - Do batch augmentations during training. Args: mean (Sequence[Number], optional): The pixel mean of R, G, B channels. Defaults to None. std (Sequence[Number], optional): The pixel standard deviation of R, G, B channels. Defaults to None. pad_size_divisor (int): The size of padded image should be divisible by ``pad_size_divisor``. Defaults to 1. pad_value (Number): The padded pixel value. Defaults to 0. pad_mask (bool): Whether to pad instance masks. Defaults to False. mask_pad_value (int): The padded pixel value for instance masks. Defaults to 0. pad_seg (bool): Whether to pad semantic segmentation maps. Defaults to False. seg_pad_value (int): The padded pixel value for semantic segmentation maps. Defaults to 255. bgr_to_rgb (bool): whether to convert image from BGR to RGB. Defaults to False. rgb_to_bgr (bool): whether to convert image from RGB to RGB. Defaults to False. boxtype2tensor (bool): Whether to keep the ``BaseBoxes`` type of bboxes data or not. Defaults to True. non_blocking (bool): Whether block current process when transferring data to device. Defaults to False. batch_augments (list[dict], optional): Batch-level augmentations """ def __init__(self, mean: Sequence[Number] = None, std: Sequence[Number] = None, pad_size_divisor: int = 1, pad_value: Union[float, int] = 0, pad_mask: bool = False, mask_pad_value: int = 0, pad_seg: bool = False, seg_pad_value: int = 255, bgr_to_rgb: bool = False, rgb_to_bgr: bool = False, boxtype2tensor: bool = True, non_blocking: Optional[bool] = False, batch_augments: Optional[List[dict]] = None): super().__init__( mean=mean, std=std, pad_size_divisor=pad_size_divisor, pad_value=pad_value, bgr_to_rgb=bgr_to_rgb, rgb_to_bgr=rgb_to_bgr, non_blocking=non_blocking) if batch_augments is not None: self.batch_augments = nn.ModuleList( [MODELS.build(aug) for aug in batch_augments]) else: self.batch_augments = None self.pad_mask = pad_mask self.mask_pad_value = mask_pad_value self.pad_seg = pad_seg self.seg_pad_value = seg_pad_value self.boxtype2tensor = boxtype2tensor def forward(self, data: dict, training: bool = False) -> dict: """Perform normalization、padding and bgr2rgb conversion based on ``BaseDataPreprocessor``. Args: data (dict): Data sampled from dataloader. training (bool): Whether to enable training time augmentation. Returns: dict: Data in the same format as the model input. """ batch_pad_shape = self._get_pad_shape(data) data = super().forward(data=data, training=training) inputs, data_samples = data['inputs'], data['data_samples'] if data_samples is not None: # NOTE the batched image size information may be useful, e.g. # in DETR, this is needed for the construction of masks, which is # then used for the transformer_head. batch_input_shape = tuple(inputs[0].size()[-2:]) for data_sample, pad_shape in zip(data_samples, batch_pad_shape): data_sample.set_metainfo({ 'batch_input_shape': batch_input_shape, 'pad_shape': pad_shape }) if self.boxtype2tensor: samplelist_boxtype2tensor(data_samples) if self.pad_mask and training: self.pad_gt_masks(data_samples) if self.pad_seg and training: self.pad_gt_sem_seg(data_samples) if training and self.batch_augments is not None: for batch_aug in self.batch_augments: inputs, data_samples = batch_aug(inputs, data_samples) return {'inputs': inputs, 'data_samples': data_samples} def _get_pad_shape(self, data: dict) -> List[tuple]: """Get the pad_shape of each image based on data and pad_size_divisor.""" _batch_inputs = data['inputs'] # Process data with `pseudo_collate`. if is_seq_of(_batch_inputs, torch.Tensor): batch_pad_shape = [] for ori_input in _batch_inputs: pad_h = int( np.ceil(ori_input.shape[1] / self.pad_size_divisor)) * self.pad_size_divisor pad_w = int( np.ceil(ori_input.shape[2] / self.pad_size_divisor)) * self.pad_size_divisor batch_pad_shape.append((pad_h, pad_w)) # Process data with `default_collate`. elif isinstance(_batch_inputs, torch.Tensor): assert _batch_inputs.dim() == 4, ( 'The input of `ImgDataPreprocessor` should be a NCHW tensor ' 'or a list of tensor, but got a tensor with shape: ' f'{_batch_inputs.shape}') pad_h = int( np.ceil(_batch_inputs.shape[1] / self.pad_size_divisor)) * self.pad_size_divisor pad_w = int( np.ceil(_batch_inputs.shape[2] / self.pad_size_divisor)) * self.pad_size_divisor batch_pad_shape = [(pad_h, pad_w)] * _batch_inputs.shape[0] else: raise TypeError('Output of `cast_data` should be a dict ' 'or a tuple with inputs and data_samples, but got' f'{type(data)}: {data}') return batch_pad_shape def pad_gt_masks(self, batch_data_samples: Sequence[DetDataSample]) -> None: """Pad gt_masks to shape of batch_input_shape.""" if 'masks' in batch_data_samples[0].gt_instances: for data_samples in batch_data_samples: masks = data_samples.gt_instances.masks data_samples.gt_instances.masks = masks.pad( data_samples.batch_input_shape, pad_val=self.mask_pad_value) def pad_gt_sem_seg(self, batch_data_samples: Sequence[DetDataSample]) -> None: """Pad gt_sem_seg to shape of batch_input_shape.""" if 'gt_sem_seg' in batch_data_samples[0]: for data_samples in batch_data_samples: gt_sem_seg = data_samples.gt_sem_seg.sem_seg h, w = gt_sem_seg.shape[-2:] pad_h, pad_w = data_samples.batch_input_shape gt_sem_seg = F.pad( gt_sem_seg, pad=(0, max(pad_w - w, 0), 0, max(pad_h - h, 0)), mode='constant', value=self.seg_pad_value) data_samples.gt_sem_seg = PixelData(sem_seg=gt_sem_seg) @MODELS.register_module() class BatchSyncRandomResize(nn.Module): """Batch random resize which synchronizes the random size across ranks. Args: random_size_range (tuple): The multi-scale random range during multi-scale training. interval (int): The iter interval of change image size. Defaults to 10. size_divisor (int): Image size divisible factor. Defaults to 32. """ def __init__(self, random_size_range: Tuple[int, int], interval: int = 10, size_divisor: int = 32) -> None: super().__init__() self.rank, self.world_size = get_dist_info() self._input_size = None self._random_size_range = (round(random_size_range[0] / size_divisor), round(random_size_range[1] / size_divisor)) self._interval = interval self._size_divisor = size_divisor def forward( self, inputs: Tensor, data_samples: List[DetDataSample] ) -> Tuple[Tensor, List[DetDataSample]]: """resize a batch of images and bboxes to shape ``self._input_size``""" h, w = inputs.shape[-2:] if self._input_size is None: self._input_size = (h, w) scale_y = self._input_size[0] / h scale_x = self._input_size[1] / w if scale_x != 1 or scale_y != 1: inputs = F.interpolate( inputs, size=self._input_size, mode='bilinear', align_corners=False) for data_sample in data_samples: img_shape = (int(data_sample.img_shape[0] * scale_y), int(data_sample.img_shape[1] * scale_x)) pad_shape = (int(data_sample.pad_shape[0] * scale_y), int(data_sample.pad_shape[1] * scale_x)) data_sample.set_metainfo({ 'img_shape': img_shape, 'pad_shape': pad_shape, 'batch_input_shape': self._input_size }) data_sample.gt_instances.bboxes[ ..., 0::2] = data_sample.gt_instances.bboxes[..., 0::2] * scale_x data_sample.gt_instances.bboxes[ ..., 1::2] = data_sample.gt_instances.bboxes[..., 1::2] * scale_y if 'ignored_instances' in data_sample: data_sample.ignored_instances.bboxes[ ..., 0::2] = data_sample.ignored_instances.bboxes[ ..., 0::2] * scale_x data_sample.ignored_instances.bboxes[ ..., 1::2] = data_sample.ignored_instances.bboxes[ ..., 1::2] * scale_y message_hub = MessageHub.get_current_instance() if (message_hub.get_info('iter') + 1) % self._interval == 0: self._input_size = self._get_random_size( aspect_ratio=float(w / h), device=inputs.device) return inputs, data_samples def _get_random_size(self, aspect_ratio: float, device: torch.device) -> Tuple[int, int]: """Randomly generate a shape in ``_random_size_range`` and broadcast to all ranks.""" tensor = torch.LongTensor(2).to(device) if self.rank == 0: size = random.randint(*self._random_size_range) size = (self._size_divisor * size, self._size_divisor * int(aspect_ratio * size)) tensor[0] = size[0] tensor[1] = size[1] barrier() broadcast(tensor, 0) input_size = (tensor[0].item(), tensor[1].item()) return input_size @MODELS.register_module() class BatchFixedSizePad(nn.Module): """Fixed size padding for batch images. Args: size (Tuple[int, int]): Fixed padding size. Expected padding shape (h, w). Defaults to None. img_pad_value (int): The padded pixel value for images. Defaults to 0. pad_mask (bool): Whether to pad instance masks. Defaults to False. mask_pad_value (int): The padded pixel value for instance masks. Defaults to 0. pad_seg (bool): Whether to pad semantic segmentation maps. Defaults to False. seg_pad_value (int): The padded pixel value for semantic segmentation maps. Defaults to 255. """ def __init__(self, size: Tuple[int, int], img_pad_value: int = 0, pad_mask: bool = False, mask_pad_value: int = 0, pad_seg: bool = False, seg_pad_value: int = 255) -> None: super().__init__() self.size = size self.pad_mask = pad_mask self.pad_seg = pad_seg self.img_pad_value = img_pad_value self.mask_pad_value = mask_pad_value self.seg_pad_value = seg_pad_value def forward( self, inputs: Tensor, data_samples: Optional[List[dict]] = None ) -> Tuple[Tensor, Optional[List[dict]]]: """Pad image, instance masks, segmantic segmentation maps.""" src_h, src_w = inputs.shape[-2:] dst_h, dst_w = self.size if src_h >= dst_h and src_w >= dst_w: return inputs, data_samples inputs = F.pad( inputs, pad=(0, max(0, dst_w - src_w), 0, max(0, dst_h - src_h)), mode='constant', value=self.img_pad_value) if data_samples is not None: # update batch_input_shape for data_sample in data_samples: data_sample.set_metainfo({ 'batch_input_shape': (dst_h, dst_w), 'pad_shape': (dst_h, dst_w) }) if self.pad_mask: for data_sample in data_samples: masks = data_sample.gt_instances.masks data_sample.gt_instances.masks = masks.pad( (dst_h, dst_w), pad_val=self.mask_pad_value) if self.pad_seg: for data_sample in data_samples: gt_sem_seg = data_sample.gt_sem_seg.sem_seg h, w = gt_sem_seg.shape[-2:] gt_sem_seg = F.pad( gt_sem_seg, pad=(0, max(0, dst_w - w), 0, max(0, dst_h - h)), mode='constant', value=self.seg_pad_value) data_sample.gt_sem_seg = PixelData(sem_seg=gt_sem_seg) return inputs, data_samples @MODELS.register_module() class MultiBranchDataPreprocessor(BaseDataPreprocessor): """DataPreprocessor wrapper for multi-branch data. Take semi-supervised object detection as an example, assume that the ratio of labeled data and unlabeled data in a batch is 1:2, `sup` indicates the branch where the labeled data is augmented, `unsup_teacher` and `unsup_student` indicate the branches where the unlabeled data is augmented by different pipeline. The input format of multi-branch data is shown as below : .. code-block:: none { 'inputs': { 'sup': [Tensor, None, None], 'unsup_teacher': [None, Tensor, Tensor], 'unsup_student': [None, Tensor, Tensor], }, 'data_sample': { 'sup': [DetDataSample, None, None], 'unsup_teacher': [None, DetDataSample, DetDataSample], 'unsup_student': [NOne, DetDataSample, DetDataSample], } } The format of multi-branch data after filtering None is shown as below : .. code-block:: none { 'inputs': { 'sup': [Tensor], 'unsup_teacher': [Tensor, Tensor], 'unsup_student': [Tensor, Tensor], }, 'data_sample': { 'sup': [DetDataSample], 'unsup_teacher': [DetDataSample, DetDataSample], 'unsup_student': [DetDataSample, DetDataSample], } } In order to reuse `DetDataPreprocessor` for the data from different branches, the format of multi-branch data grouped by branch is as below : .. code-block:: none { 'sup': { 'inputs': [Tensor] 'data_sample': [DetDataSample, DetDataSample] }, 'unsup_teacher': { 'inputs': [Tensor, Tensor] 'data_sample': [DetDataSample, DetDataSample] }, 'unsup_student': { 'inputs': [Tensor, Tensor] 'data_sample': [DetDataSample, DetDataSample] }, } After preprocessing data from different branches, the multi-branch data needs to be reformatted as: .. code-block:: none { 'inputs': { 'sup': [Tensor], 'unsup_teacher': [Tensor, Tensor], 'unsup_student': [Tensor, Tensor], }, 'data_sample': { 'sup': [DetDataSample], 'unsup_teacher': [DetDataSample, DetDataSample], 'unsup_student': [DetDataSample, DetDataSample], } } Args: data_preprocessor (:obj:`ConfigDict` or dict): Config of :class:`DetDataPreprocessor` to process the input data. """ def __init__(self, data_preprocessor: ConfigType) -> None: super().__init__() self.data_preprocessor = MODELS.build(data_preprocessor) def forward(self, data: dict, training: bool = False) -> dict: """Perform normalization、padding and bgr2rgb conversion based on ``BaseDataPreprocessor`` for multi-branch data. Args: data (dict): Data sampled from dataloader. training (bool): Whether to enable training time augmentation. Returns: dict: - 'inputs' (Dict[str, obj:`torch.Tensor`]): The forward data of models from different branches. - 'data_sample' (Dict[str, obj:`DetDataSample`]): The annotation info of the sample from different branches. """ if training is False: return self.data_preprocessor(data, training) # Filter out branches with a value of None for key in data.keys(): for branch in data[key].keys(): data[key][branch] = list( filter(lambda x: x is not None, data[key][branch])) # Group data by branch multi_branch_data = {} for key in data.keys(): for branch in data[key].keys(): if multi_branch_data.get(branch, None) is None: multi_branch_data[branch] = {key: data[key][branch]} elif multi_branch_data[branch].get(key, None) is None: multi_branch_data[branch][key] = data[key][branch] else: multi_branch_data[branch][key].append(data[key][branch]) # Preprocess data from different branches for branch, _data in multi_branch_data.items(): multi_branch_data[branch] = self.data_preprocessor(_data, training) # Format data by inputs and data_samples format_data = {} for branch in multi_branch_data.keys(): for key in multi_branch_data[branch].keys(): if format_data.get(key, None) is None: format_data[key] = {branch: multi_branch_data[branch][key]} elif format_data[key].get(branch, None) is None: format_data[key][branch] = multi_branch_data[branch][key] else: format_data[key][branch].append( multi_branch_data[branch][key]) return format_data @property def device(self): return self.data_preprocessor.device def to(self, device: Optional[Union[int, torch.device]], *args, **kwargs) -> nn.Module: """Overrides this method to set the :attr:`device` Args: device (int or torch.device, optional): The desired device of the parameters and buffers in this module. Returns: nn.Module: The model itself. """ return self.data_preprocessor.to(device, *args, **kwargs) def cuda(self, *args, **kwargs) -> nn.Module: """Overrides this method to set the :attr:`device` Returns: nn.Module: The model itself. """ return self.data_preprocessor.cuda(*args, **kwargs) def cpu(self, *args, **kwargs) -> nn.Module: """Overrides this method to set the :attr:`device` Returns: nn.Module: The model itself. """ return self.data_preprocessor.cpu(*args, **kwargs) @MODELS.register_module() class BatchResize(nn.Module): """Batch resize during training. This implementation is modified from https://github.com/Purkialo/CrowdDet/blob/master/lib/data/CrowdHuman.py. It provides the data pre-processing as follows: - A batch of all images will pad to a uniform size and stack them into a torch.Tensor by `DetDataPreprocessor`. - `BatchFixShapeResize` resize all images to the target size. - Padding images to make sure the size of image can be divisible by ``pad_size_divisor``. Args: scale (tuple): Images scales for resizing. pad_size_divisor (int): Image size divisible factor. Defaults to 1. pad_value (Number): The padded pixel value. Defaults to 0. """ def __init__( self, scale: tuple, pad_size_divisor: int = 1, pad_value: Union[float, int] = 0, ) -> None: super().__init__() self.min_size = min(scale) self.max_size = max(scale) self.pad_size_divisor = pad_size_divisor self.pad_value = pad_value def forward( self, inputs: Tensor, data_samples: List[DetDataSample] ) -> Tuple[Tensor, List[DetDataSample]]: """resize a batch of images and bboxes.""" batch_height, batch_width = inputs.shape[-2:] target_height, target_width, scale = self.get_target_size( batch_height, batch_width) inputs = F.interpolate( inputs, size=(target_height, target_width), mode='bilinear', align_corners=False) inputs = self.get_padded_tensor(inputs, self.pad_value) if data_samples is not None: batch_input_shape = tuple(inputs.size()[-2:]) for data_sample in data_samples: img_shape = [ int(scale * _) for _ in list(data_sample.img_shape) ] data_sample.set_metainfo({ 'img_shape': tuple(img_shape), 'batch_input_shape': batch_input_shape, 'pad_shape': batch_input_shape, 'scale_factor': (scale, scale) }) data_sample.gt_instances.bboxes *= scale data_sample.ignored_instances.bboxes *= scale return inputs, data_samples def get_target_size(self, height: int, width: int) -> Tuple[int, int, float]: """Get the target size of a batch of images based on data and scale.""" im_size_min = np.min([height, width]) im_size_max = np.max([height, width]) scale = self.min_size / im_size_min if scale * im_size_max > self.max_size: scale = self.max_size / im_size_max target_height, target_width = int(round(height * scale)), int( round(width * scale)) return target_height, target_width, scale def get_padded_tensor(self, tensor: Tensor, pad_value: int) -> Tensor: """Pad images according to pad_size_divisor.""" assert tensor.ndim == 4 target_height, target_width = tensor.shape[-2], tensor.shape[-1] divisor = self.pad_size_divisor padded_height = (target_height + divisor - 1) // divisor * divisor padded_width = (target_width + divisor - 1) // divisor * divisor padded_tensor = torch.ones([ tensor.shape[0], tensor.shape[1], padded_height, padded_width ]) * pad_value padded_tensor = padded_tensor.type_as(tensor) padded_tensor[:, :, :target_height, :target_width] = tensor return padded_tensor @MODELS.register_module() class BoxInstDataPreprocessor(DetDataPreprocessor): """Pseudo mask pre-processor for BoxInst. Comparing with the :class:`mmdet.DetDataPreprocessor`, 1. It generates masks using box annotations. 2. It computes the images color similarity in LAB color space. Args: mask_stride (int): The mask output stride in boxinst. Defaults to 4. pairwise_size (int): The size of neighborhood for each pixel. Defaults to 3. pairwise_dilation (int): The dilation of neighborhood for each pixel. Defaults to 2. pairwise_color_thresh (float): The thresh of image color similarity. Defaults to 0.3. bottom_pixels_removed (int): The length of removed pixels in bottom. It is caused by the annotation error in coco dataset. Defaults to 10. """ def __init__(self, *arg, mask_stride: int = 4, pairwise_size: int = 3, pairwise_dilation: int = 2, pairwise_color_thresh: float = 0.3, bottom_pixels_removed: int = 10, **kwargs) -> None: super().__init__(*arg, **kwargs) self.mask_stride = mask_stride self.pairwise_size = pairwise_size self.pairwise_dilation = pairwise_dilation self.pairwise_color_thresh = pairwise_color_thresh self.bottom_pixels_removed = bottom_pixels_removed if skimage is None: raise RuntimeError('skimage is not installed,\ please install it by: pip install scikit-image') def get_images_color_similarity(self, inputs: Tensor, image_masks: Tensor) -> Tensor: """Compute the image color similarity in LAB color space.""" assert inputs.dim() == 4 assert inputs.size(0) == 1 unfolded_images = unfold_wo_center( inputs, kernel_size=self.pairwise_size, dilation=self.pairwise_dilation) diff = inputs[:, :, None] - unfolded_images similarity = torch.exp(-torch.norm(diff, dim=1) * 0.5) unfolded_weights = unfold_wo_center( image_masks[None, None], kernel_size=self.pairwise_size, dilation=self.pairwise_dilation) unfolded_weights = torch.max(unfolded_weights, dim=1)[0] return similarity * unfolded_weights def forward(self, data: dict, training: bool = False) -> dict: """Get pseudo mask labels using color similarity.""" det_data = super().forward(data, training) inputs, data_samples = det_data['inputs'], det_data['data_samples'] if training: # get image masks and remove bottom pixels b_img_h, b_img_w = data_samples[0].batch_input_shape img_masks = [] for i in range(inputs.shape[0]): img_h, img_w = data_samples[i].img_shape img_mask = inputs.new_ones((img_h, img_w)) pixels_removed = int(self.bottom_pixels_removed * float(img_h) / float(b_img_h)) if pixels_removed > 0: img_mask[-pixels_removed:, :] = 0 pad_w = b_img_w - img_w pad_h = b_img_h - img_h img_mask = F.pad(img_mask, (0, pad_w, 0, pad_h), 'constant', 0.) img_masks.append(img_mask) img_masks = torch.stack(img_masks, dim=0) start = int(self.mask_stride // 2) img_masks = img_masks[:, start::self.mask_stride, start::self.mask_stride] # Get origin rgb image for color similarity ori_imgs = inputs * self.std + self.mean downsampled_imgs = F.avg_pool2d( ori_imgs.float(), kernel_size=self.mask_stride, stride=self.mask_stride, padding=0) # Compute color similarity for pseudo mask generation for im_i, data_sample in enumerate(data_samples): # TODO: Support rgb2lab in mmengine? images_lab = skimage.color.rgb2lab( downsampled_imgs[im_i].byte().permute(1, 2, 0).cpu().numpy()) images_lab = torch.as_tensor( images_lab, device=ori_imgs.device, dtype=torch.float32) images_lab = images_lab.permute(2, 0, 1)[None] images_color_similarity = self.get_images_color_similarity( images_lab, img_masks[im_i]) pairwise_mask = (images_color_similarity >= self.pairwise_color_thresh).float() per_im_bboxes = data_sample.gt_instances.bboxes if per_im_bboxes.shape[0] > 0: per_im_masks = [] for per_box in per_im_bboxes: mask_full = torch.zeros((b_img_h, b_img_w), device=self.device).float() mask_full[int(per_box[1]):int(per_box[3] + 1), int(per_box[0]):int(per_box[2] + 1)] = 1.0 per_im_masks.append(mask_full) per_im_masks = torch.stack(per_im_masks, dim=0) pairwise_masks = torch.cat( [pairwise_mask for _ in range(per_im_bboxes.shape[0])], dim=0) else: per_im_masks = torch.zeros((0, b_img_h, b_img_w)) pairwise_masks = torch.zeros( (0, self.pairwise_size**2 - 1, b_img_h, b_img_w)) # TODO: Support BitmapMasks with tensor? data_sample.gt_instances.masks = BitmapMasks( per_im_masks.cpu().numpy(), b_img_h, b_img_w) data_sample.gt_instances.pairwise_masks = pairwise_masks return {'inputs': inputs, 'data_samples': data_samples}