Сохранение изображений в файл: ResourceSave

MQL5 API позволяет записать ресурс в файл формата BMP с помощью функции ResourceSave. В данный момент среда поддерживает только ресурсы-изображения.

bool ResourceSave(const string resource, const string filename)

В параметрах resource и filename указываются, соответственно, имя ресурса и файла. Имя ресурса должно начинаться с "::". Имя файла может содержать путь относительно папки MQL5/Files. При необходимости функция создаст все промежуточные подкаталоги. Если указанный файл существует, он будет перезаписан.

Функция возвращает true в случае успеха.

Для проверки работы данной функции желательно создать оригинальное изображение. И у нас есть для этого подходящий "материал".

В рамках изучения ООП, в главе Классы и интерфейсы, мы начали серию примеров про графические фигуры: от самой первой версии Shapes1.mq5 в разделе про Определение класса и до последней Shapes6.mq5 в разделе про Вложенные типы. Само рисование тогда не было нам доступно, вплоть до главы про графические объекты, где мы смогли реализовать визуализацию в скрипте ObjectShapesDraw.mq5. Теперь, после изучения графических ресурсов, настало время очередного "апгрейда".

В новой версии скрипта ResourceShapesDraw.mq5 фигуры будут по-настоящему рисоваться. Чтобы было проще анализировать изменения по сравнению с прежней версией, мы оставим тот же набор фигур: прямоугольник, квадрат, овал, круг и треугольник. Это сделано для примера, а не потому что в рисовании нас что-то ограничивает: наоборот — существует потенциал по расширению набора фигур, визуальных эффектов и нанесению надписей. Некоторые из этих возможностей мы рассмотрим в этом примере, некоторые — в следующих, но продемонстрировать все многообразие применений в рамках книги просто невозможно.

После того как фигуры будут сгенерированы и отрисованы, мы сохраним получившийся ресурс в файл.

Напомним, что основой иерархии классов фигур является Shape, в котором был метод draw.

class Shape
{
public:
   ...
   virtual void draw() = 0;
   ...
}

В производных классах он был реализован на основе графических объектов, с вызовами ObjectCreate и последующей настройкой объектов ObjectSet-функциями. Общим холстом такого рисунка был непосредственно график.

Сейчас нам потребуется закрашивать пиксели в некотором общем ресурсе в соответствии с формой конкретной фигуры. Общий ресурс и методы модификации пикселей в нем желательно выделить в отдельный класс или лучше интерфейс.

Абстрактная сущность позволит не делать увязки со способом создания и настройкой ресурса. В частности, наша последующая реализация поместит ресурс в объект OBJ_BITMAP_LABEL (как мы уже делали в этой главе), а для кого-то может быть достаточно генерировать изображения в памяти и сохранять на диск, не выводя на график (так многие трейдеры любят периодически фиксировать состояния чартов).

Назовем интерфейс Drawing.

interface Drawing
{
   void point(const float x1const float y1const uint pixel);
   void line(const int x1const int y1const int x2const int y2const color clr);
   void rect(const int x1const int y1const int x2const int y2const color clr);
};

Здесь представлены лишь три самых базовых метода для рисования. Нам этого хватит.

Метод point хотя и является публичным (что дает возможность поставить отдельную точку), но в некотором смысле — низкоуровневый, так как через него будут реализованы все остальные. Именно поэтому в нем координаты сделаны вещественными, а содержимое пикселя — готовым значением типа uint. Это позволит при необходимости подключить различные алгоритмы сглаживания, чтобы фигуры не выглядели ступенчатыми из-за пикселизации. Здесь мы не будем касаться этого вопроса.

С учетом интерфейса метод Shape::draw превращается в такой:

virtual void draw(Drawing *drawing) = 0;

Тогда в классе Rectangle очень просто поручить отрисовку прямоугольника новому интерфейсу.

class Rectangle : public Shape
{
protected:
   int dxdy// размер (ширина, высота)
   ...
public:
   void draw(Drawing *drawingoverride
   {
      // x, y - точка привязки (центр) в Shape
      drawing.rect(x – dx / 2y – dy / 2x + dx / 2y + dy / 2backgroundColor);
   }
};

Для рисования эллипса придется потрудиться побольше.

class Ellipse : public Shape
{
protected:
   int dxdy// большой и малый радиусы
   ...
public:
   void draw(Drawing *drawingoverride
   {
      // (x, y) - центр
      const int hh = dy * dy;
      const int ww = dx * dx;
      const int hhww = hh * ww;
      int x0 = dx;
      int step = 0;
      
      // главный горизонтальный диаметр
      drawing.line(x - dxyx + dxybackgroundColor);
      
      // горизонтальные линии в верхней и нижней половине, симметрично уменьшающиеся в длине
      for(int j = 1j <= dyj++)
      {
         for(int x1 = x0 - (step - 1); x1 > 0; --x1)
         {
            if(x1 * x1 * hh + j * j * ww <= hhww)
            {
               step = x0 - x1;
               break;
            }
         }
         x0 -= step;
         drawing.line(x - x0y - jx + x0y - jbackgroundColor);
         drawing.line(x - x0y + jx + x0y + jbackgroundColor);
      }
   }
};

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

class Trianglepublic Shape
{
protected:
   int dx;  // один размер, т.к. треугольники равносторонние
   ...
public:
   virtual void draw(Drawing *drawingoverride
   {
      // (x, y) - центр
      // R = a * sqrt(3) / 3
      // p0: x, y + R
      // p1: x - R * cos(30), y - R * sin(30)
      // p2: x + R * cos(30), y - R * sin(30)
      // высота по теореме Пифагора: dx * dx = dx * dx / 4 + h * h
      // sqrt(dx * dx * 3/4) = h
      const double R = dx * sqrt(3) / 3;
      const double H = sqrt(dx * dx * 3 / 4);
      const double angle = H / (dx / 2);
      
      // главная вертикальная линия (высота треугольника)
      const int base = y + (int)(R - H);
      drawing.line(xy + (int)RxbasebackgroundColor);
      
      // вертикальные линии влево и вправо меньшего размера, симметричные
      for(int j = 1j <= dx / 2; ++j)
      {
         drawing.line(x - jy + (int)(R - angle * j), x - jbasebackgroundColor);
         drawing.line(x + jy + (int)(R - angle * j), x + jbasebackgroundColor);
      }
   }
};

Обратимся теперь к классу MyDrawing — наследнику интерфейса Drawing. Именно MyDrawing должен, руководствуясь вызовами интерфейсных методов в фигурах, обеспечить отображение в растровой картинке некоего ресурса. Поэтому внутри класса, прежде всего, описаны переменные для названий графического объекта (object) и ресурса (sheet), а также массив data типа uint для хранения картинки. Кроме того мы перенесли сюда массив фигур shapes, который ранее просто был объявлен в обработчике OnStart. Поскольку за рисование всех фигур отвечает MyDrawing, то и управлять их набором лучше здесь.

class MyDrawingpublic Drawing
{
   const string object;     // объект с bitmap-ом
   const string sheet;      // ресурс
   uint data[];             // пиксели
   int widthheight;       // размеры
   AutoPtr<Shapeshapes[]; // фигуры
   const uint bg;           // цвет фона
   ...

В конструкторе создаем графический объект на размер всего графика и выделяем память под массив data. Холст заполняется нулями (означает "черную прозрачность") или другим значением, переданным в параметре background, после чего на его основе создается ресурс. По умолчанию имя ресурса начинается с буквы 'D' и включает идентификатор текущего графика, но можно задать другое.

public:
   MyDrawing(const uint background = 0const string s = NULL) :
      object((s == NULL ? "Drawing" : s)),
      sheet("::" + (s == NULL ? "D" + (string)ChartID() : s)), bg(background)
   {
      width = (int)ChartGetInteger(0CHART_WIDTH_IN_PIXELS);
      height = (int)ChartGetInteger(0CHART_HEIGHT_IN_PIXELS);
      ArrayResize(datawidth * height);
      ArrayInitialize(databackground);
   
      ResourceCreate(sheetdatawidthheight00widthCOLOR_FORMAT_ARGB_NORMALIZE);
      
      ObjectCreate(0objectOBJ_BITMAP_LABEL000);
      ObjectSetInteger(0objectOBJPROP_XDISTANCE0);
      ObjectSetInteger(0objectOBJPROP_YDISTANCE0);
      ObjectSetInteger(0objectOBJPROP_XSIZEwidth);
      ObjectSetInteger(0objectOBJPROP_YSIZEheight);
      ObjectSetString(0objectOBJPROP_BMPFILEsheet);
   }

Вызывающий код может узнать имя ресурса с помощью метода resource.

   string resource() const
   {
      return sheet;
   }

В деструкторе ресурс и объект удаляются.

   ~MyDrawing()
   {
      ResourceFree(sheet);
      ObjectDelete(0object);
   }

Для заполнения массива фигур предусмотрен метод push.

   Shape *push(Shape *shape)
   {
      shapes[EXPAND(shapes)] = shape;
      return shape;
   }

Для их рисования определен метод draw, который просто в цикле вызывает метод draw каждой фигуры, а затем обновляет ресурс и график.

   void draw()
   {
      for(int i = 0i < ArraySize(shapes); ++i)
      {
         shapes[i][].draw(&this);
      }
      ResourceCreate(sheetdatawidthheight00widthCOLOR_FORMAT_ARGB_NORMALIZE);
      ChartRedraw();
   }

Осталось рассмотреть самые главные методы — методы интерфейса Drawing, которые собственно и обеспечивают рисование.

Начнем с метода point, который пока приведем в упрощенном виде (усовершенствованиями займемся позднее).

   virtual void point(const float x1const float y1const uint pixeloverride
   {
      const int x_main = (int)MathRound(x1);
      const int y_main = (int)MathRound(y1);
      const int index = y_main * width + x_main;
      if(index >= 0 && index < ArraySize(data))
      {
         data[index] = pixel;
      }
   }

На основе point легко сделать рисование линии. Когда координаты начальной и конечной точки совпадают по одному из измерений, мы делегируем рисование методу rect: ведь прямая линия — это вырожденный случай прямоугольника единичной толщины.

   virtual void line(const int x1const int y1const int x2const int y2const color clroverride
   {
      if(x1 == x2rect(x1y1x1y2clr);
      else if(y1 == y2rect(x1y1x2y1clr);
      else
      {
         const uint pixel = ColorToARGB(clr);
         double angle = 1.0 * (y2 - y1) / (x2 - x1);
         if(fabs(angle) < 1// шаг по оси с наибольшим расстоянием, по x
         {
            const int sign = x2 > x1 ? +1 : -1;
            for(int i = 0i <= fabs(x2 - x1); ++i)
            {
               const float p = (float)(y1 + sign * i * angle);
               point(x1 + sign * ippixel);
            }
         }
         else // или шаг по y
         {
            const int sign = y2 > y1 ? +1 : -1;
            for(int i = 0i <= fabs(y2 - y1); ++i)
            {
               const float p = (float)(x1 + sign * i / angle);
               point(py1 + sign * ipixel);
            }
         }
      }
   }

А вот и сам метод rect.

   virtual void rect(const int x1const int y1const int x2const int y2const color clroverride
   {
      const uint pixel = ColorToARGB(clr);
      for(int i = fmin(x1x2); i <= fmax(x1x2); ++i)
      {
         for(int j = fmin(y1y2); j <= fmax(y1y2); ++j)
         {
            point(ijpixel);
         }
      }
   }

Осталось модифицировать обработчик OnStart, и скрипт будет готов.

В начале мы настраиваем график (скрываем все элементы). В принципе, это не обязательно: оставлено для сопоставления со скриптом-прототипом.

void OnStart()
{
   ChartSetInteger(0CHART_SHOWfalse);
   ...

Далее описываем объект класса MyDrawing, генерируем предопределенное количество случайных фигур (здесь всё осталось без изменений, включая генератор addRandomShape и макрос FIGURES, равный 21-у), рисуем их в ресурсе и выводим в объекте на графике.

   MyDrawing raster;
   
   for(int i = 0i < FIGURES; ++i)
   {
      raster.push(addRandomShape());
   }
   
   raster.draw(); // выводим начальное состояние
   ...

Напомним, что в примере ObjectShapesDraw.mq5 мы затем начинали бесконечный цикл, в котором хаотично двигали фигуры. Повторим этот прием и здесь. Но для него потребуется дополнить класс MyDrawing — он должен этим заняться, раз массив фигур хранится у него внутри. Напишем простой метод shake.

class MyDrawingpublic Drawing
{
public:
   ...
   void shake()
   {
      ArrayInitialize(databg);
      for(int i = 0i < ArraySize(shapes); ++i)
      {
         shapes[i][].move(random(20) - 10random(20) - 10);
      }
   }
   ...
};

Тогда в OnStart мы можем задействовать новый метод в цикле, пока пользователь не остановит анимацию.

void OnStart()
{
   ...
   while(!IsStopped())
   {
      Sleep(250);
      raster.shake();
      raster.draw();
   }
   ...
}

На этом функционал прежнего примера фактически повторен. Но нам нужно добавить сохранение картинки в файл. Поэтому добавим входной параметр SaveImage.

input bool SaveImage = false;

Когда он будет установлен в true, проверим функцию ResourceSave в деле.

void OnStart()
{
   ...
   if(SaveImage)
   {
      const string filename = "temp.bmp";
      if(ResourceSave(raster.resource(), filename))
      {
         Print("Bitmap image saved: "filename);
      }
      else
      {
         Print("Can't save image "filename", "E2S(_LastError));
      }
   }
}

Кроме того, раз уж речь зашла о входных переменных, позволим пользователю выбирать фон и передадим полученное значение в конструктор MyDrawing.

input color BackgroundColor = clrNONE;
void OnStart()
{
   ...
   MyDrawing raster(BackgroundColor != clrNONE ? ColorToARGB(BackgroundColor) : 0);
   ...
}

Итак, все готово для первого испытания.

Если запустить скрипт ResourceShapesDraw.mq5, на графике сформируется изображение вроде следующего.

Растровое изображение ресурса с набором случайных фигур

Растровое изображение ресурса с набором случайных фигур

При сравнении данного изображения с тем, что мы видели в примере ObjectShapesDraw.mq5, обнаруживается, что наш новый способ визуализации несколько отличается от того, каким терминал выводит объекты. Хотя формы фигур и их цвет в отдельности не вызывают вопросов, места наложения фигур обозначаются по-разному.

Наш скрипт закрашивает фигуры указанным цветом, накладывая их друг на друга в порядке следования в массиве. Более поздние фигуры перекрывают более ранние. Терминал же применяет в местах перекрытия некое смешение цветов (инверсию).

Оба способа имеют право на существование, здесь нет ошибок. Однако интересно, нельзя ли добиться при рисовании аналогичного эффекта?

Благодаря тому, что мы полностью управляем процессом рисования, к нему можно применить любые эффекты, не только такой, как в терминале.

Давайте реализуем, в дополнение к исходному, простому способу рисования, еще несколько режимов. Все они сведены в перечисление COLOR_EFFECT.

enum COLOR_EFFECT
{
   PLAIN,         // простое рисование с перекрытием (по умолчанию)
   COMPLEMENT,    // рисование дополнительным цветом (как в терминале) 
   BLENDING_XOR,  // смешивание цветов с помощью XOR '^'
   DIMMING_SUM,   // "затемнение" цветов с помощью '+'
   LIGHTEN_OR,    // "осветление" цветов с помощью '|'
};

Добавим входную переменную для выбора режима.

input COLOR_EFFECT ColorEffect = PLAIN;

Поддержим режимы в классе MyDrawing. Для начала опишем соответствующие поле и метод.

class MyDrawingpublic Drawing
{
   ...
   COLOR_EFFECT xormode;
   ...
public:
   void setColorEffect(const COLOR_EFFECT x)
   {
      xormode = x;
   }
   ...

Затем усовершенствуем метод point.

   virtual void point(const float x1const float y1const uint pixeloverride
   {
      ...
      if(index >= 0 && index < ArraySize(data))
      {
         switch(xormode)
         {
         case COMPLEMENT:
            data[index] = (pixel ^ (1 - data[index])); // смешивание с дополнительным цветом
            break;
         case BLENDING_XOR:
            data[index] = (pixel & 0xFF000000) | (pixel ^ data[index]); // прямое смешивание (XOR)
            break;
         case DIMMING_SUM:
            data[index] =  (pixel + data[index]); // "затемнение" (SUM)
            break;
         case LIGHTEN_OR:
            data[index] =  (pixel & 0xFF000000) | (pixel | data[index]); // "осветление" (OR)
            break;
         case PLAIN:
         default:
            data[index] = pixel;
         }
      }
   }

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

Изображение фигур с осветляющим смешиванием цветов

Изображение фигур с осветляющим смешиванием цветов

Чтобы наглядно увидеть разницу в эффектах, вы можете отключить рандомизацию цветов и движение фигур. Стандартный способ перекрытия объектов соответствует константе COMPLEMENT.

В качестве финального эксперимента, включите опцию SaveImage. В обработчике OnStart при генерации имени файла с изображением мы теперь используем название текущего режима. Мы должны получить в файле копию изображения на графике.

   ...
   if(SaveImage)
   {
      const string filename = EnumToString(ColorEffect) + ".bmp";
      if(ResourceSave(raster.resource(), filename))
      ...

Для более изощренных графических построений нашего интерфейса Drawing будет, скорее всего, недостаточно. Поэтому вы можете использовать готовые классы для рисования, поставляемые с MetaTrader 5 или доступные в базе кодов на mql5.com. В частности, загляните в файл MQL5/Include/Canvas/Canvas.mqh.