Реализация увеличения изображения на JavaScript

вторник, 23 октября 2012 г.

Введение


Есть проект, который позволяет пользователям добавлять собственные графические изображения их меток на карту. Загрузив собственное изображение, необходимо указать точку привязки — та точка, которой будут соответствовать географические координаты на карте. Указать желаемую точку привязки с точностью до пикселя будет достаточно проблематично, поэтому возникла идея сделать лупу, которая должна была показывать увеличенное изображение метки с использованием интерполяции nearest, чтобы дать возможно выбрать именно тот пиксель изображения, который захочет требовательный пользователь :)

Обычно эффект лупы делают таким образом:
  1. берем оригинальное изображение;
  2. делаем его уменьшенную копию;
  3. при движении курсора на уменьшенной копией, высчитываем координаты оригинального изображения, которые соответствуют положению курсора над первой, и выводим нужный нам кусочек оригинала в нужном нам месте.
В данном алгоритме изображения, обычно, готовят заранее. Но у нас изображения загружает пользователь, поэтому генерацию увеличенного изображения нужно делать налету.

Какие способы существуют для создания увеличенного изображения? Посмотрим:
  1. библиотека GD для PHP;
  2. ImageMagick;
  3. помещать изображение в img с увеличенным размером;
  4. HTML5 Canvas.
В первом и во втором случае увеличение изображения происходило бы на стороне сервера, нам это не интересно, потому что грузит сервер, съедает память, нагружает канал связи, да и просто банально :) Третий вариант также не подходит — интерполяция не та, что нам нужна, и не во всех браузерах одинакова. А вот четвертый вариант нам интересен.

Canvas


В HTML5 Canvas есть возможность общаться с изображением на уровне пикселей, т. е. можно создавать различные графические фильтры… да и все что угодно, связанное с обработкой изображений, на чистом JavaScript. Вопрос только в скорости работы, и вот какая она будет — мы узнаем в процессе написания кода.

Работа с изображениями в canvas в общем виде выглядит так:

<!DOCTYPE HTML>
<html>
<head>
  <style>
    body {
      margin: 0px;
      padding: 0px;
    }
    #myCanvas {
      border: 1px solid #9C9898;
    }
  </style>
</head>
<body>
  <canvas id="myCanvas" width="578" height="400"></canvas>
  <script>
    var canvas = document.getElementById('myCanvas');
    var context = canvas.getContext('2d');
    var imageObj = new Image();
    imageObj.onload = function() {
      context.drawImage(imageObj, 69, 50);
    };
    imageObj.src = 'http://www.html5canvastutorials.com/demos/assets/darth-vader.jpg';
  </script>
</body>
</html>

Пример честно заимствован с сайта www.html5canvastutorials.com

В этом примере изображение «darth-vader.jpg» загружается и отрисовывается на canvas с помощью функции drawImage.

Но нам нужно написать код для увеличения изображения. Почитав документацию к canvas и изучив примеры, код увеличения изображения будет следующим:

function scale_up_nearest(input, scale_factor)
{
  var self = this;

  self.canvas.width = input.width;
  self.canvas.height = input.height;

  var context  = self.canvas.getContext('2d');

  context.drawImage(input, 0, 0);

  var inputImageData = context.getImageData(0, 0, self.canvas.width, self.canvas.height);  

  /**  
  * let's scale up the image  
  */
  var old_width = self.canvas.width;
  var old_height = self.canvas.height;  
  var new_width = Math.max(Math.floor(scale_factor*old_width),1);  
  var new_height = Math.max(Math.floor(scale_factor*old_height),1);  

  self.canvas.width = new_width;  
  self.canvas.height = new_height;  
  
  var outputImageData = context.createImageData(new_width, new_height);
  var outputImageData_data = outputImageData.data;  
  var inputImageData_data = inputImageData.data;

  for(var j = 0; j < new_height; j++ )
  { 
    for( var i = 0; i < new_width; i++ ) 
    {
      var i_input = Math.min(Math.round((i-0.5)/scale_factor+0.5), old_width);
      var j_input = Math.min(Math.round((j-0.5)/scale_factor+0.5), old_height);
      
      var new_index = j * new_width * 4 + (i * 4);
      var old_index = j_input * old_width * 4 + (i_input * 4);
      
      outputImageData_data[new_index] = inputImageData_data[old_index];
      outputImageData_data[new_index+1] = inputImageData_data[old_index+1];
      outputImageData_data[new_index+2] = inputImageData_data[old_index+2];
      outputImageData_data[new_index+3] = inputImageData_data[old_index+3];
    } 
  } 
  context.putImageData(outputImageData, 0, 0);  
  var dataURL = self.canvas.toDataURL("image/png");  
  var outputImage = new Image();  
  outputImage.src = dataURL;  

  return outputImage;
}


Для оценки скорости работы этого кода проведем тестирование: запустим код несколько раз в нескольких браузерах, запишем время выполнения и вычислим среднее значение, которое и будет для нас результатом. Исходное изображение будет иметь разрешение 300x379, увеличивать мы его будем в 10 раз (до 3000x3790). Вот что у меня получилось:

Результат очень плачевный в IE9, но не забываем, что мы увеличиваем достаточно крупное изображение в 10 раз.

Попробуем немного упростить задачу. Возможно, нам не нужна возможность увеличивать изображение в n раз, где n — не целое число. Тогда мы можем убрать кучу математических функций вроде Math.round.

После внесенных изменений код стал таким:

function scale_up_nearest(input, scale_factor)
{
  var self = this;
 
  self.canvas.width = input.width;
  self.canvas.height = input.height;
  
  var context  = self.canvas.getContext('2d');
  
  context.drawImage(input, 0, 0);

  var inputImageData = context.getImageData(0, 0, self.canvas.width, self.canvas.height);

  /**
   * let's scale up the image
   */
  var old_width = self.canvas.width;
  var old_height = self.canvas.height;
  var new_width = old_width * scale_factor;
  var new_height = old_height * scale_factor;

  self.canvas.width = new_width;
  self.canvas.height = new_height;

  var outputImageData = context.createImageData(new_width, new_height);

  var outputImageData_data = outputImageData.data;
  var inputImageData_data = inputImageData.data;

  for(var j = 0; j < new_height; j++ )
  {
    for( var i = 0; i < new_width; i++ )
    {
      var i_input = parseInt(i / scale_factor);
      var j_input = parseInt(j / scale_factor);

      var new_index = j * new_width * 4 + (i * 4);
      var old_index = j_input * old_width * 4 + (i_input * 4);

      outputImageData_data[new_index] = inputImageData_data[old_index];
      outputImageData_data[new_index+1] = inputImageData_data[old_index+1];
      outputImageData_data[new_index+2] = inputImageData_data[old_index+2];
      outputImageData_data[new_index+3] = inputImageData_data[old_index+3];
    }
  }
  context.putImageData(outputImageData, 0, 0);
  var dataURL = self.canvas.toDataURL("image/png");
  var outputImage = new Image();
  outputImage.src = dataURL;

  return outputImage;
}



Результат тестирования:


В Опере и в IE скорость работы значительно возросла, в Firefox незначительно, а Chrome, к моему удивлению, замедлился почти в два раза.

Попробуем еще чуть чуть оптимизировать скрипт, вынеся некоторые константные выражения за цикл, а также, заменив умножение сдвигом.

Код получился таким:

function scale_up_nearest(input, scale_factor)
{
  var self = this;

  self.canvas.width = input.width;
  self.canvas.height = input.height;

  var context  = self.canvas.getContext('2d');

  context.drawImage(input, 0, 0);

  var inputImageData = context.getImageData(0, 0, self.canvas.width, self.canvas.height);

  /**
   * let's scale up the image
   */
  var old_width = self.canvas.width;
  var old_height = self.canvas.height;
  var new_width = old_width * scale_factor;
  var new_height = old_height * scale_factor;

  self.canvas.width = new_width;
  self.canvas.height = new_height;

  var outputImageData = context.createImageData(new_width, new_height);

  /**
   * precompute some data for optimisation purposes
   */
  var new_width_4_precomp = new_width << 2;
  var old_width_4_precomp = old_width << 2;
  var outputImageData_data = outputImageData.data;
  var inputImageData_data = inputImageData.data;

  for(var j = 0; j < new_height; j++ )
  {
    for( var i = 0; i < new_width; i++ )
    {
      var i_input = parseInt(i / scale_factor);
      var j_input = parseInt(j / scale_factor);

      var new_index = j*new_width_4_precomp + (i << 2);
      var old_index = j_input*old_width_4_precomp + (i_input << 2);

      outputImageData_data[new_index++] = inputImageData_data[old_index++];
      outputImageData_data[new_index++] = inputImageData_data[old_index++];
      outputImageData_data[new_index++] = inputImageData_data[old_index++];
      outputImageData_data[new_index] = inputImageData_data[old_index];
    }
  }
  context.putImageData(outputImageData, 0, 0);
  var dataURL = self.canvas.toDataURL("image/png");
  var outputImage = new Image();
  outputImage.src = dataURL;

  return outputImage;
}

Результат практически не изменился, т. е. подобная оптимизация в данном случае не имеет смысла, если вообще имеет в интерпритируемом языке:


Сравнительная гистограмма:

Вывод


Данный подход можно использовать при работе с небольшими изображениями, не сильно их увеличивая, получая достаточную производительность. Например: Опера при увеличении того же изображения, но в 5 раз, срабатывает за 0.2-0.3 секунды.

Листинг


Файл index.html

<!DOCTYPE html>
<html>

<head>

<title>Scale up test</title>

<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js" type="text/javascript"></script>
<script src="imagemagnify.js"></script>

<script>
  $(document).ready(function()
  {
    var mag = new magnify();
    mag.init('image-wrap', 'image');
  })
</script>

<style>
  #image-wrap
  {
    float: left;
  }
</style>

</head>

<body>

<div id="image-wrap">
  <img src="i/cat.jpeg" id="image" width="300px" height="379px" />
</div>

</body>

</html>

Файл imagemagnify.js (используется jQuery)

/**
* конструктор
* @param opt не используется
*/
var magnify = function(opt)
{
  // canvas html
  this.canvasLoup = '<canvas style="display: none; position: absolute; z-index: 1000; left: 350px; top: 0; border: 1px solid RGB(0,0,0);" id="canvasLoup"></canvas>';

  // the img tag id, set in init function
  this.imgId = null;

  // the image from img tag
  this.img = null;

  // the resized image
  this.resizeImg = undefined;
  this.originalImageLoaded = false;

  // scale up factor, default: 5
  this.scale = 10;

  // html5 canvas 2d context
  this.context = undefined;

  // html5 canvas
  this.canvas = undefined;

  // size of popup window, where you can see resized image
  this.ramka = 300;

  // trigger for animation
  this.isAnim = false;

  // trigger, if crosshair image loaded
  this.crosshairImageLoaded = false;

  // crosshair image, that shows in popup window
  this.crosshairImage = null;

  this.containerId = null;
  this.container = null;
};

magnify.prototype = {

  /**
  * destroy all data in the class to free resources
  * @param event not using
  */
  destroy: function(event)
  {
    $('#canvasLoup').remove();

    if( this.crosshairImage )
      delete this.crosshairImage;

    this.crosshairImageLoaded = false;

    if( this.resizeImg )
      delete this.resizeImg;

    $('#'+this.containerId).unbind('mousemove', self.mousemove)
      .unbind('mouseout', self.mouseout)
      .unbind('mouseover', self.mouseover);

    if( this.img )
      delete this.img;
  },

  /**
  * for realizing mousewheel function
  * really not using at this time
  * @param delta
  */
  over: function(delta) {

    if( delta > 0 )
    {
      if( window.magnify.scale < 6 )
      {
        window.magnify.scale ++;
        window.magnify.init(null);
      }
    }
    else
    {
      if( window.magnify.scale > 1)
      {
        window.magnify.scale --;
        window.magnify.init(null);
      }
    }

  },

  /**
  * for profiling this class
  * @param get_as_float
  */
  microtime: function(get_as_float) {
    var now = new Date().getTime() / 1000;
    var s = parseInt(now);

    return (get_as_float) ? now : (Math.round((now - s) * 1000) / 1000) +  ' ' +  s;
  },

  /**
  * init function, must be called after object has been created
  * @param id - img tag id, without # or other jquery elements, only similar text
  */
  init: function(image_container_id, image_id)
  {
    if( image_id )
    {
      this.imgId = image_id;
    }
    else
      return false;

    if( image_container_id )
    {
      this.containerId = image_container_id;
      this.container = $('#'+this.containerId);
    }
    else
      return false;

    var self = this;

    /**
    * loading crosshair image
    */
    self.crosshairImage = new Image();
    self.crosshairImage.src = 'i/crosshair.png';
    self.crosshairImage.onload = function()
    {
      self.crosshairImageLoaded = true;
    };

    /**
    * adding canvas tag to container
    */
    this.container.append(self.canvasLoup);

    /**
    * initializing 2d context
    */
    self.canvas = document.getElementById('canvasLoup');
    self.context = self.canvas.getContext('2d');

    /**
    * getting original image from img tag, accessing by img id
    */
    var original_image = document.getElementById(this.imgId);

    /**
    * getting jquery object for img tag, for binding events to it
    */
    this.img = $('#'+self.imgId);

    /**
    * resize image
    */
    this.resizeImg = null;

    var id = setTimeout(function()
    {
      if( original_image.complete)
      {
        clearTimeout(id);
        self.resizeImg = self.scale_up_nearest(original_image, self.scale);
        self.originalImageLoaded = true;

        /**
        * set canvas size
        */
        self.canvas.width = self.ramka;
        self.canvas.height = self.ramka;
      }
    }, 50);

    /**
    * bind events
    */
    $('#'+image_container_id).bind('mouseover', {self: this}, self.mouseover)
      .bind('mouseout', {self: this}, self.mouseout)
      .bind('mousemove', {self: this}, self.mousemove);

  },

  /**
  * draw part of the resized image to context at x and y
  * @param x
  * @param y
  */
  draw: function(x,y)
  {
    if( this.originalImageLoaded )
    {
      this.context.rect(0, 0, this.canvas.width, this.canvas.height);
      this.context.fillStyle = 'white';
      this.context.fill();
      this.context.drawImage(this.resizeImg, -(x)*this.scale + (this.ramka >> 1), -(y)*this.scale + (this.ramka >> 1));

      if( this.crosshairImageLoaded )
      {
        this.context.drawImage(
          this.crosshairImage,
          0,
          0,
          this.crosshairImage.width,
          this.crosshairImage.height,
          (this.canvas.width >> 1) - (this.crosshairImage.width >> 1),
          (this.canvas.height >> 1) - (this.crosshairImage.height >> 1) - 2,
          this.crosshairImage.width,
          this.crosshairImage.height
        );
      }
    }
  },

  mouseover: function(event)
  {
    event.data.self.show();
  },

  mouseout: function(event)
  {
    event.data.self.hide();
  },

  mousemove: function(event)
  {
    $(this).css('cursor', 'crosshair');
    var imgOffset = $(event.data.self.img).offset();

    var position = $(event.data.self.container).css('position');

    var canvasOffsetX = event.pageX + 10;
    var canvasOffsetY = event.pageY - event.data.self.ramka - 10;

    if( position == 'relative' || position == 'absolute' )
    {
      canvasOffsetX -= imgOffset['left'];
      canvasOffsetY -= imgOffset['top'];
    }

     $('#canvasLoup')
      .css({left:canvasOffsetX, top: canvasOffsetY});

     event.data.self.draw(event.pageX - imgOffset['left'], event.pageY - imgOffset['top']);
  },

  show: function()
  {
    var self = this;

    if( this.isAnim )
    {
      $('#canvasLoup').stop(true, true);
    }

    this.isAnim = true;

    $('#canvasLoup').fadeIn(500, function()
    {
      self.isAnim = false
    });
  },

  hide: function()
  {
    var self = this;

    if( this.isAnim )
    {
      $('#canvasLoup').stop(true, true);
    }

    this.isAnim = true;

    $('#canvasLoup').fadeOut(500, function()
    {
      self.isAnim = false
    });
  },

  scale_up_nearest: function(input, scale_factor)
  {
    var self = this;

    self.canvas.width = input.width;
    self.canvas.height = input.height;

    var context  = self.canvas.getContext('2d');

    context.drawImage(input, 0, 0);

    var inputImageData = context.getImageData(0, 0, self.canvas.width, self.canvas.height);

    /**
    * let's scale up the image
    */
    var old_width = self.canvas.width;
    var old_height = self.canvas.height;
    var new_width = old_width * scale_factor;
    var new_height = old_height * scale_factor;

    self.canvas.width = new_width;
    self.canvas.height = new_height;

    var outputImageData = context.createImageData(new_width, new_height);

    /**
    * precompute some data for optimisation purposes
    */
    var new_width_4_precomp = new_width << 2;
    var old_width_4_precomp = old_width << 2;
    var outputImageData_data = outputImageData.data;
    var inputImageData_data = inputImageData.data;

    for(var j = 0; j < new_height; j++ )
    {
      for( var i = 0; i < new_width; i++ )
      {
        var i_input = parseInt(i / scale_factor);
        var j_input = parseInt(j / scale_factor);

        var new_index = j*new_width_4_precomp + (i << 2);
        var old_index = j_input*old_width_4_precomp + (i_input << 2);

        outputImageData_data[new_index++] = inputImageData_data[old_index++];
        outputImageData_data[new_index++] = inputImageData_data[old_index++];
        outputImageData_data[new_index++] = inputImageData_data[old_index++];
        outputImageData_data[new_index] = inputImageData_data[old_index++];
      }
    }
    context.putImageData(outputImageData, 0, 0);
    var dataURL = self.canvas.toDataURL("image/png");
    var outputImage = new Image();
    outputImage.src = dataURL;

    return outputImage;
  }   
};

Комментариев нет:

Отправить комментарий