Получение изображения нужного размера без OutOfMemoryError + автоповорот согласно EXIF orientation

Многие уже наверняка сталкивались с проблемой OutOfMemoryError и находили достаточно толковый мануал Displaying Bitmaps Efficiently. Но если вы еще не успели изобрести свой велосипед на основе мануала, предлагаю свое готовое решение с объяснениями, которое умеет получать изображения:

  • В формате Bitmap и byte[]
  • Уменьшенное с сохранением пропорций
  • Уменьшенное с вырезанием (crop) до заданного размера width x height
  • Оптимизированное для 2g
  • Всегда в правильной ориентации (учитывая EXIF orientation)


Пример использования
ImageManager im = new ImageManager(ctx, 100, 100);
Bitmap bm = im.setIsScale(true)
              .setIsResize(true)
              .setIsCrop(true)
              .getFromFile(myUri.toString());




OutOfMemoryError


Почему происходит эта ошибка? Все дело в том, что на каждое приложение выделяется ограниченное количество памяти (heap size), разное в зависимости от устройства. Например, 16мб, 24мб и выше. Современные устройства как правило имеют 24мб и выше, однако и эти величины можно быстро «съесть».

Что же именно поглощает память? Ответ кроется в классе Bitmap, который на каждый пиксель тратит в общем случае 2 или 4 байта (зависит от битности изображения – 16бит RGB_555 или 32 бита ARGB_888). Посчитаем сколько съест Bitmap, содержащий изображение, снятое на 5 мегапиксельную камеру.

При соотношении сторон 4:3 получится изображение со сторонами 2583 х 1936. В RGB_555 конфигурации наш Bitmap займет 2583 * 1936 * 2 = 9.54Мб (здесь и далее считаю, что Мб = 2 в 20 степени байт), а в ARGB_888 в 2 раза больше – чуть более 19Мб. Про камеры с бОльшим количеством мегапикселей подумать страшно.

Решение — коротко и ясно.

1) Используя функцию BitmapFactory.decodeStream с переданным третьим параметром new BitmapFactory.Options(), у которого inJustDecodeBounds = true получаем Bitmap содержащий только размеры изображения в пикселях и не содержащий самих пикселей.
2) Определяем во сколько раз нужно уменьшить изображение, чтобы получить нужные нам размеры.
3) Присваеваем это значение полю inSampleSize инстанса BitmapFactory.Options и снова вызываем функцию BitmapFactory.decodeStream.
4) Гарантируется, что декодер вернет уменьшенное изображение без OutOfMemoryError

Примечание: Не вижу смысла делать размер изображения больше чем размер экрана. Также не вижу смысла хранить Bitmap в конфигурации ARGB_888, поскольку многие девайсы имеют 16 битные экраны. Но даже и на более цветастых экранах выгода от двукратного уменьшения потребляемой памяти выше, чем незначительное снижение качества изображения (ИМХО).

Пример
InputStream in = ... //Ваш InputStream
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeStream(in, null, o);
in.close();
int origWidth = o.outWidth; //исходная ширина 
int origHeight = o.outHeight; //исходная высота 
int bytesPerPixel = 2 //соответствует RGB_555 конфигурации
int maxSize = 480 * 800 * bytesPerPixel; //Максимально разрешенный размер Bitmap
int desiredWidth = …; //Нужная ширина
int desiredHeight = …; //Нужная высота
int desiredSize = _ desiredWidth * _ desiredHeight * bytesPerPixel; //Максимально разрешенный размер Bitmap для заданных width х height
if (desiredSize < maxSize) maxSize = desiredSize;
int scale = 1; //кратность уменьшения
int origSize = origWidth * origHeight * bytesPerPixel;
//высчитываем кратность уменьшения
if (origWidth > origHeight) {
    scale = Math.round((float) origHeight / (float) desiredHeight);
} else {
    scale = Math.round((float) origWidth / (float) desiredWidth);
}

o = new BitmapFactory.Options();
o.inSampleSize = scale;
o.inPreferredConfig = Config.RGB_565;
            
in = … //Ваш InputStream. Важно - открыть его нужно еще раз, т.к второй раз читать из одного и того же InputStream не разрешается (Проверено на ByteArrayInputStream и FileInputStream).
Bitmap bitmap = BitmapFactory.decodeStream(in, null, o); //Полученный Bitmap



Что дальше?

Если точное соответствие ширине и высоте вам не требуется, то полученного Bitmap’а достаточно, иначе ресайзим и/или обрезаем изображение. Реализация этих функций тривиальна, исходные коды в конце поста.

EXIF orientation или исправляем перевернутые изображения.


Данное решение применимо только к формату jpeg.

Гарантии, что предметы на изображении всегда будут повернуты так, как мы их видим – нет. Достаточно повернуть камеру смартфона на любой угол – и вот вам изображение, которое особо нигде не используешь. Но хочется, чтобы дома и люди стояли на земле, а птицы летели по небу. На помощь приходить EXIF – формат, позволяющий добавлять дополнительную информацию к изображениям.

Нас интересует лишь один параметр – orientation, который камера смарфона добавляет в метаданные (хочется верить, что камеры всех устройств это делают). Но в сыром виде он хранит не градус поворота, а цифровое значение 1-8. Что означают эти значения, описано здесь. Честно говоря, я не стал заучивать, что они означают, поэтому рекомендую взять готовую функцию в конце поста, которая переводит эти значения в градусы: getOrientation(Context context, Uri uri). Функция возвращает значения 90, 180, 270, 0 или -1 (ошибочное состояние, когда по той или иной причине определить угол поворота не удалось). Соответственно, поворот требуется при значении > 0.

Чтобы вернуть изображение в правильный ракурс, нужно дополнить код по получению изображения:

Вместо:

int origWidth = o.outWidth; //исходная ширина 
int origHeight = o.outHeight; //исходная высота


Напишем:

int origWidth = 0; //исходная ширина 
int origHeight = 0; //исходная высота
if (orientation == 90 || orientation == 270) {
  origWidth = o.outHeight;
  origHeight = o.outWidth;
} else {
  origWidth = o.outWidth;
  origHeight = o.outHeight;
}


А в конце добавим:

if (orientation > 0) {
     Matrix matrix = new Matrix();
     matrix.postRotate(orientation);
     Bitmap decodedBitmap = bitmap;
     bitmap = Bitmap.createBitmap(decodedBitmap, 0, 0, bitmap.getWidth(),
     bitmap.getHeight(), matrix, true);
     //рецайклим оригинальный битмап за ненадобностью
     if (decodedBitmap != null && !decodedBitmap.equals(bitmap)) {
         decodedBitmap.recycle();
     }
}


UPDATE 1

Оптимизируем изображение


Ширина и высота

Класс ImageManager использует два энума ScaleMode и ResizeMode. Каждый имеет два значения EQUAL_OR_GREATER и EQUAL_OR_LOWER.

Для ScaleMode:
  • EQUAL_OR_GREATER будет означать, что картинка, уменьшенная декодером будет всегда равна или чуть больше заданных width x height
  • EQUAL_OR_LOWER будет означать, что картинка, уменьшенная декодером будет всегда чуть меньше заданных width x height


Для ResizeMode:
  • EQUAL_OR_GREATER будет означать, что обе стороны картинки будут равны или больше заданных width x height
  • EQUAL_OR_LOWER будет означать, что картинка будет вписана в прямоугольник, заданный width x height (все стороны равны или меньше заданных width x height)


Если размер изображения, потребляемая память, место на SD карте, скорость загрузки по 2g/3g — не вторичны, то рекомендуется использовать режим EQUAL_OR_LOWER, который позволит снизить эти параметры за счет незначительного снижения качества изображения. На практике я достаточно быстро пришел к этому компромиссу, т.к при отправке/получении изображений на 2g скорость как правило оставляет желать лучшего (особенно в условиях нестабильного сигнала, который бывает отнюдь не только в тоннелях и метро).

Jpeg compression rate

Но если ScaleMode и ResizeMode позволяют «экономить» на ширине и высоте, то есть еще один способ снизить размер изображения: jpeg compression rate. Для этого в классе ImageManager я написал метод и его перегрузку:

byte[] getRawFromFile(String path, int compressRate)
byte[] getRawFromFile(String path)


В перегрузке метода хардкодом вбит параметр эмпирически выведенный параметр 75. Данный метод я использую вкупе с ScaleMode и ResizeMode для получения оптимизированной jpeg картинки в виде byte array, что самое то для отправки её в post'е на сервер.

Послесловие


Надеюсь сей мануал окажется кому нибудь не только полезным, но и даст понимание. Ибо бездумный копипаст может решить проблему в краткосрочном периоде, но в долгосрочном может привести к еще большим ошибкам.

Листинг класса ImageManager
public final class ImageManager {    
    private Context _ctx;
    private int _boxWidth;
    private int _boxHeight;
    private ResizeMode _resizeMode;
    private ScaleMode _scaleMode;
    private Config _rgbMode;
    private boolean _isScale;
    private boolean _isResize;
    private boolean _isCrop;
    private boolean _isRecycleSrcBitmap;
    private boolean _useOrientation;
    
    public ImageManager(Context ctx, int boxWidth, int boxHeight) {
        this(ctx);
        _boxWidth = boxWidth;
        _boxHeight = boxHeight;
    }
    
    public ImageManager(Context ctx) {
        _ctx = ctx;
        _isScale = false;
        _isResize = false;
        _isCrop = false;
        _isRecycleSrcBitmap = true;
        _useOrientation = false;
    }
    
    public ImageManager setResizeMode(ResizeMode mode) {
        _resizeMode = mode;
        return this;
    }
    
    public ImageManager setScaleMode(ScaleMode mode) {
        _scaleMode = mode;
        return this;
    }
    
    public ImageManager setRgbMode(Config mode) {
        _rgbMode = mode;
        return this;
    }
    
    public ImageManager setIsScale(boolean isScale) {
        _isScale = isScale;
        return this;
    }
    
    public ImageManager setIsResize(boolean isResize) {
        _isResize = isResize;
        return this;
    }
    
    public ImageManager setIsCrop(boolean isCrop) {
        _isCrop = isCrop;
        return this;
    }
    
    public ImageManager setUseOrientation(boolean value) {
        _useOrientation = value;
        return this;
    }
    
    public ImageManager setIsRecycleSrcBitmap(boolean value) {
        _isRecycleSrcBitmap = value;
        return this;
    }
    
    public Bitmap getFromFile(String path) {
        Uri uri = Uri.parse(path);
        int orientation = -1;
        if (_useOrientation) {
            orientation = getOrientation(_ctx, uri);
        }
        Bitmap bitmap = scale(new StreamFromFile(_ctx, path), orientation);
        return getFromBitmap(bitmap);
    }
    
    public Bitmap getFromBitmap(Bitmap bitmap) {
        if (bitmap == null) return null;
        if (_isResize) bitmap = resize(bitmap);
        if (_isCrop) bitmap = crop(bitmap);
        return bitmap;
    }
    
    public byte[] getRawFromFile(String path) {
        return getRawFromFile(path, 75);
    }

    public byte[] getRawFromFile(String path, int compressRate) {
        Bitmap scaledBitmap = getFromFile(path);
        if (scaledBitmap == null) return null;
        
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        scaledBitmap.compress(CompressFormat.JPEG, compressRate, output);
        recycleBitmap(scaledBitmap);
        
        byte[] rawImage = output.toByteArray();
        if (rawImage == null) {

            return null;
        }
        
        return rawImage;
    }
    
    public Bitmap getFromByteArray(byte[] rawImage) {
        Bitmap bitmap = scale(new StreamFromByteArray(rawImage), -1);
        return getFromBitmap(bitmap);
    }
    
    @SuppressLint("NewApi")
    private Bitmap scale(IStreamGetter streamGetter, int orientation) {        
        try {
            InputStream in = streamGetter.Get();
            if (in == null) return null;
            
            Bitmap bitmap = null;
            Config rgbMode = _rgbMode != null ? _rgbMode : Config.RGB_565;
            
            if (!_isScale) {
                BitmapFactory.Options o = new BitmapFactory.Options();
                o.inPreferredConfig = rgbMode;
                if (android.os.Build.VERSION.SDK_INT >= 11) {
                    o.inMutable = true;
                }
                bitmap = BitmapFactory.decodeStream(in, null, o);
                in.close();
                return bitmap;
            }
            
            if (_boxWidth == 0 || _boxHeight == 0) {

                if (in != null) in.close();
                return null;
            }
            
            ScaleMode scaleMode = _scaleMode != null ? _scaleMode : ScaleMode.EQUAL_OR_GREATER;
            int bytesPerPixel = rgbMode == Config.ARGB_8888 ? 4 : 2;
            int maxSize = 480 * 800 * bytesPerPixel;
            int desiredSize = _boxWidth * _boxHeight * bytesPerPixel;
            if (desiredSize < maxSize) maxSize = desiredSize;
            
            BitmapFactory.Options o = new BitmapFactory.Options();
            o.inJustDecodeBounds = true;
            BitmapFactory.decodeStream(in, null, o);
            in.close();
            int scale = 1;
            
            int origWidth;
            int origHeight;
            if (orientation == 90 || orientation == 270) {
                origWidth = o.outHeight;
                origHeight = o.outWidth;
            } else {
                origWidth = o.outWidth;
                origHeight = o.outHeight;
            }
            
            while ((origWidth * origHeight * bytesPerPixel) * (1 / Math.pow(scale, 2)) > maxSize) {
                scale++;
            }
            if (scaleMode == ScaleMode.EQUAL_OR_LOWER) {
                scale++;
            }
            
            o = new BitmapFactory.Options();
            o.inSampleSize = scale;
            o.inPreferredConfig = rgbMode;
            
            in = streamGetter.Get();
            if (in == null) return null;
            bitmap = BitmapFactory.decodeStream(in, null, o);
            in.close();
            
            if (orientation > 0) {
                Matrix matrix = new Matrix();
                matrix.postRotate(orientation);
                Bitmap decodedBitmap = bitmap;
                bitmap = Bitmap.createBitmap(decodedBitmap, 0, 0, bitmap.getWidth(),
                        bitmap.getHeight(), matrix, true);
                if (decodedBitmap != null && !decodedBitmap.equals(bitmap)) {
                    recycleBitmap(decodedBitmap);
                }
            }
            
            return bitmap;
        }
        catch (IOException e) {

            return null;
        }
    }
    
    private Bitmap resize(Bitmap sourceBitmap) {
        if (sourceBitmap == null) return null;
        if (_resizeMode == null) _resizeMode = ResizeMode.EQUAL_OR_GREATER;
        float srcRatio;
        float boxRatio;
        int srcWidth = 0;
        int srcHeight = 0;
        int resizedWidth = 0;
        int resizedHeight = 0;

        srcWidth = sourceBitmap.getWidth();
        srcHeight = sourceBitmap.getHeight();

        if (_resizeMode == ResizeMode.EQUAL_OR_GREATER && (srcWidth <= _boxWidth || srcHeight <= _boxHeight) ||
            _resizeMode == ResizeMode.EQUAL_OR_LOWER && srcWidth <= _boxWidth && srcHeight <= _boxHeight) {

            return sourceBitmap;
        }
        
        srcRatio = (float)srcWidth / (float)srcHeight;
        boxRatio = (float)_boxWidth / (float)_boxHeight;

        if (srcRatio > boxRatio && _resizeMode == ResizeMode.EQUAL_OR_GREATER ||
            srcRatio < boxRatio && _resizeMode == ResizeMode.EQUAL_OR_LOWER) {
            resizedHeight = _boxHeight;
            resizedWidth = (int)((float)resizedHeight * srcRatio);
        }
        else {
            resizedWidth = _boxWidth;
            resizedHeight = (int)((float)resizedWidth / srcRatio);
        }
        
        Bitmap resizedBitmap = Bitmap.createScaledBitmap(sourceBitmap, resizedWidth, resizedHeight, true);
        
        if (_isRecycleSrcBitmap && !sourceBitmap.equals(resizedBitmap)) {
            recycleBitmap(sourceBitmap);
        }

        return resizedBitmap;
    }
    
    private Bitmap crop(Bitmap sourceBitmap) {
        if (sourceBitmap == null) return null;
        int srcWidth = sourceBitmap.getWidth();
        int srcHeight = sourceBitmap.getHeight();
        int croppedX = 0;
        int croppedY = 0;
        
        croppedX = (srcWidth > _boxWidth) ? (int)((srcWidth - _boxWidth) / 2) : 0;
        croppedY = (srcHeight > _boxHeight) ? (int)((srcHeight - _boxHeight) / 2) : 0;
        
        if (croppedX == 0 && croppedY == 0) 
            return sourceBitmap;
        
        Bitmap croppedBitmap = null;
        try {
            croppedBitmap = Bitmap.createBitmap(sourceBitmap, croppedX, croppedY, _boxWidth, _boxHeight);
        }
        catch(Exception e) {

        }
        if (_isRecycleSrcBitmap && !sourceBitmap.equals(croppedBitmap)) {
            recycleBitmap(sourceBitmap);
        }

        return croppedBitmap;
    }
    
    public static void recycleBitmap(Bitmap bitmap) {
        if (bitmap == null || bitmap.isRecycled()) return;
        bitmap.recycle();
        System.gc();
    }
    
    private static interface IStreamGetter {
        public InputStream Get();
    }
    
    private static class StreamFromFile implements IStreamGetter {
        private String _path;
        private Context _ctx;
        public StreamFromFile(Context ctx, String path) {
            _path = path;
            _ctx = ctx;
        }
        @SuppressWarnings("resource")
        public InputStream Get() {
            try {
                Uri uri = Uri.parse(_path);
                return "content".equals(uri.getScheme()) 
                        ? _ctx.getContentResolver().openInputStream(uri)
                        : new FileInputStream(_path);
            }
            catch (FileNotFoundException e) {

                return null;
            }
        }
    }
    
    private static class StreamFromByteArray implements IStreamGetter {
        private byte[] _rawImage;
        public StreamFromByteArray(byte[] rawImage) {
            _rawImage = rawImage;
        }
        public InputStream Get() {
            if (_rawImage == null) return null;
            return new ByteArrayInputStream(_rawImage);
        }
    }
    
    private static int getOrientation(Context context, Uri uri) {
        if ("content".equals(uri.getScheme())) {
            Cursor cursor = context.getContentResolver().query(uri,
                    new String[] { MediaStore.Images.ImageColumns.ORIENTATION }, null, null, null);
            
            if (cursor == null || cursor.getCount() != 1) {
                return -1;
            }
    
            cursor.moveToFirst();
            int orientation = cursor.getInt(0);
            cursor.close();
            return orientation;
        }
        else {
            try {
                ExifInterface exif = new ExifInterface(uri.getPath());
                int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1);
                switch (orientation) {
                    case ExifInterface.ORIENTATION_ROTATE_270:
                        return 270;
                    case ExifInterface.ORIENTATION_ROTATE_180:
                        return 180;
                    case ExifInterface.ORIENTATION_ROTATE_90:
                        return 90;
                    case ExifInterface.ORIENTATION_NORMAL:
                        return 0;
                    default:
                        return -1;
                }
            } catch (IOException e) {
                return -1;
            }
        }
    }
}

Похожие публикации

Средняя зарплата в IT

113 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 5 444 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 24

    0
    Что касается OutOfMemory:
    До 3.0 данные картинки хранятся в нативном коде, можно использовать вот этот хак: habrahabr.ru/post/139717/ и забить ими всю доступную память на устройстве :)
    В 3.0 и выше данные лежат прямо в виде byte[] в самом Bitmap — тут уже, к сожалению, так обдурить DVM не получится, но можно прописать приложению атрибут largeHeap в манифесте — на некоторых устройствах ему тогда дадут дофига памяти (например, на SGS3 получается 256 Мб).
      0
      Спасибо за дополнение
      0
      Спасибо за статью. Думаю, многим окажется полезной данная информация.

      4) Гарантируется, что декодер вернет уменьшенное изображение без OutOfMemoryError

      Это, наверное, если надо декодировать всего одно изображение. На практике же не всегда так радужно получается. code.google.com/p/android/issues/detail?id=8488
        +1
        Да, конечно, можно держать в памяти несколько изображений 480х800 и забить память. Однако, какой профит держать столько больших изображений в памяти? Если взять за пример галерею, то в один момент времени отображается одна большая картинка. Можно конечно делать прелоад в память соседних картинок для более быстрого отображения, но нужно представлять себе сколько памяти вы можете на это потратить, отталкиваясь от heap size конкретного устройства.
          0
          Ну тот же ViewPager, как минимум, создает заранее View слева и справа. Плюс, если картинки пережимаются или загружаются из интернета, то неплохо бы было кешировать хотя бы часть.
            +1
            Кэширование это тема для целой отдельной статьи. Если интересно кому-нибудь, могу написать пост про CacheManager, который умеет загружать картинки из нета и кешировать их в файловом кэшe и в памяти на основе алгоритма Lru и генерировать превьюшки разных размеров, в том числе с определением лица.
              0
              Было бы здорово (:
        0
        90, 180, 270 и -1 выносят мозг. Ну почему нельзя было использовать 0?
          0
          На самом деле там есть и 0 градусов, а -1 это ошибочное состояние. Сейчас исправлю, спасибо!
            0
            Ну по крайней мере в случае ExifInterface.ORIENTATION_NORMAL возвращается -1
              0
              Исправил
          0
          Спасибо, очень полезная и актуальная статья. Пятой точкой я в-принципе чувствую, что там нужно написать в классах ResizeMode и ScaleMode, но все-таки хотелось бы увидеть полный листинг.
            0
            Всё правильно, сейчас добавлю эти два энума и заодно опишу режимы, которые они задают.
            0
            4) Гарантируется, что декодер вернет уменьшенное изображение без OutOfMemoryError


            Это кем гарантируется? Если картинок много, то даже хаки не работают — кончается память. Я в результате остановился на таком решении: мониторю состояние памяти перед каждым декодированием картинки, если память занята > 50%( эмпирическое значение), то очищаю все уже декидорованные битмапы(через recycle()) и декодирую по необходимости заново.
              0
              Я постарался изолировать пример от боевых условий, чтобы был понятен смысл.

              А в боевых условиях конечно нужно еще и контролировать состояние памяти. Я могу подсказать решение попроще — LruCache (http://developer.android.com/reference/android/util/LruCache.html). Задаем лимит кэша (в зависимости от конкретного heap size например) и можем быть спокойны. LruCache есть также в support library v4.
                0
                Ну боевые условия-то самое оно. А про кэш спасибо, натыкался на него и забыл. Попробую.
              0
              А может кто-нибудь может посоветовать библиотеку, которая:
              1) Умеет выбирать картинку из галереи
              2) Получать фотографию от камеры
              3) Решает проблемы с нужным размером и автоповоротом
              Я написал своё решение, но у меня мало ресурсов для тестирования. Так что я предпочёл бы стороннее, протестированное решение.
                0
                Тоже интересно было бы узнать. Самому пришлось изобретать свой велосипед с помощью гугла и stackoverflow. В плане тестирования оказалось вполне достаточно эмуляторов и пары девайсов с 2 и 4м андроидом
                  0
                  Я тоже так думал. А потом оказалось, что на какой-то модели телефона фото неправильно переворачиваются. Подробностей уже не помню, давно было.
                    0
                    В консоли Google Play написано, что мое приложение поддерживает чуть более 2000 устройств. Это нереальное количество для тестирования. Поэтому протестируйте на паре тройке популярных моделях. Для остальных телефонов исправляйте ошибки по фидбеку. Я другого варианта не вижу.

                    P.S. Если подумать, то и для сторонней библиотеки нет никакой гарантии, что она протестирована на всех устройствах.
                0
                А как быть в том случае, когда изображение загружается из интернета? Нежелательно второй раз открывать подключение к серверу и ждать загрузки изображения.
                  0
                  Конечно, загрузка одного и того же изображения из нета съест и заряд батареи и терпение пользователя. Поэтому изображение можно и нужно сохранить на SD.
                    0
                    Вы предлагаете вначале кэшировать изображение в полном размере, а затем уже извлекать данные оттуда?
                      0
                      После загрузки картинки с нета у вас на руках есть byte[]. И тут уже сами думайте как оптимально с ним работать. У меня, например, с сервера приходят уже оптимизированные картинки и я их сразу сохраняю в файловый кеш и из того же byte[] делаю превьюшку. Т.е с одним и тем же byte[] можно проделать разные операции, смотря что требуют условия задачи.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое