Programistyczne tworzenie plików wideo

W dzisiejszym wpisie kontynuujemy naszą przygodę z biblioteką OpenCV. Jeżeli jeszcze tego nie zrobiłeś, to zachęcam do przeczytania poprzedniego wpisu: tutaj. W tym artykule zakładam, że potrafisz stworzyć własny projekt w IntelliJ, do którego dodajesz dodatkowe biblioteki oraz, że potrafisz uruchomić program i dolinkować do niego dodatkowe pliki dll. Oczywiście, cały projekt ze wszystkimi kodami źródłowymi możesz znaleźć na githubie.

Celem na dziś jest stworzenie programu, który:

  • Na wejściu: dostaje folder zawierający pliki graficzne
  • Na wyjściu: produkuje film, w którym obrazki są kolejnymi klatkami

Taki program pozwoli nam tworzyć time lapse (o których planuję zrobić zupełnie osobny wpis w najbliższym czasie).

Konfiguracja

Jeżeli chcesz pracować ze swoim projektem stworzonym w czasie lektury poprzedniego wpisu, to musisz dodać jeden plik do swojego projektu jeden dodatkowy plik ddl. Po pobraniu plików binarnych przeznaczonych dla systemu Windows ze strony https://opencv.org/releases/ i rozpakowaniu (pobrany plik to samorozpakowujące się archiwum zip) w lokalizacji opencv/build/bin znajdziesz plik opencv_videoio_ffmpeg440_64.dll. Należy ten plik umieścić w katalogu, który potem wskażesz przy uruchamianiu aplikacji przez opcję wirtualnej maszyny Javy: -Djava.library.path (w naszym poprzednim projekcie była to wartość: -Djava.library.path=lib/x64). Niestety nie ma tam paczki przeznaczonej dla architektury 32 bitowej. Jeżeli bardzo Ci na tym zależy muszę Cię odesłać do samodzielnego zbudowania biblioteki ze źródeł. Niestety (albo stety), nie musiałem tego zrobić…

Generowanie serii obrazków

Oczywiście możesz użyć dowolnej serii obrazków. Ale jeżeli nie masz pod ręką takiej kolekcji, pomocny może okazać następujący program:

public class PicturesGenerator {
    public static void main(String[] args) throws IOException {
        System.loadLibrary(Core.NATIVE_LIBRARY_NAME);

        Path res = Paths.get("resources/picts");
        Files.createDirectories(res);

        for (int i = 0; i < 200; i++) {
            Mat image = new Mat(200, 200, CvType.CV_8UC3, new Scalar(255.0, 255.0, 255.0));
            circle(image, new Point(i,i), 50, new Scalar(90.0, 60.0, 90.0), 10);
            String path = String.format("%s/pict%03d.png", res.toAbsolutePath().toString(), i);
            imwrite(path, image);
        }
    }
}

Tak jak we wszystkich programach z poprzedniego wpisu, na samym początku funkcji main umieszczamy linijkę

System.loadLibrary(Core.NATIVE_LIBRARY_NAME);

która wczytuje potrzebne biblioteki dll. Następnie tworzymy katalog resources, a w nim katalog picts używając mechanizmu NIO2 (wprowadzonego do standardu Javy w wersji 1.7):

Path res = Paths.get("resources/picts");
Files.createDirectories(res);

Sama biblioteka NIO2 zasługuje na osobny wpis, który być może pojawi się już wkrótce (tematów przybywa, a doba wciąż ma tylko 24 godziny…). Następnie w pętli, w każdej iteracji tworzymy nowy obrazek o trzech kanałach i o białym tle:

Mat image = new Mat(200, 200, CvType.CV_8UC3, new Scalar(255.0, 255.0, 255.0));

na tym pustym rysunku rysujemy okrąg korzystając z funkcji statycznej circle z klasy org.opencv.imgproc.Imgproc.

circle(image, new Point(i,i), 50, new Scalar(90.0, 60.0, 90.0), 10);

new Point(i,i) wskazuje, gdzie ma być środek rysowanego okręgu. Zwróć uwagę, że w każdym przebiegu pętli będzie to inny punkt. Liczba 50 w naszym wywołaniu funkcji circle oznacza promień rysowanego okręgu wyrażony w pikselach. Następnie new Scalar(90.0, 60.0, 90.0) oznacza kolor okręgu – w tym wypadku fioletowy. Od czasu tego filmu nie mam problemu przy wyborze losowego koloru w formacie RGB (uwaga: dla przypomnienia tylko napiszę – OpenCV korzysta z formatu BGR – wciąż się zastanawiam dlaczego…). Ostatni parametr funkcji circle oznacza grubość linii, którą narysujemy okrąg. W dalszej części ciała pętli tworzymy nazwę dla tworzonego pliku:

String path = String.format("%s/pict%03d.png", res.toAbsolutePath().toString(), i);

Warto odnotować użycie statycznej metody format z klasy String Funkcja ta przyjmuje co najmniej jeden argument – napis, który jest wzorcem dla tworzonego obiektu. Ten wzorzec zawiera w sobie placeholdery (nie lubię anglicyzmów w języku polskim, ale nie znam lepszego określenia na opisywaną rzecz) – czyli specjalne sekwencje znaków, które są zastępowane przez następnego argumenty funkcji format. I tak w naszym przykładzie mamy dwa takie placeholdery:

  • %s – w miejsce tego napisu zostanie wstawiona wartość wyrażenia res.toAbsolutePath().toString() – czyli napis zawierający ścieżkę bezwzględną do tworzonego pliku graficznego. Może podkreślmy w tym miejscu, że %s to placeholder na obiekty klasy String
  • %03d – w miejsce tego napisu zostanie wstawiona wartość zmiennej i, ale w bardzo charakterystyczny sposób. Rozłóżmy to na etapy:
    • %d to jest placeholder na liczby całkowite
    • %3d oznacza placeholder na liczby całkowite, ale w wynikowym napisie będą przeznaczone 3 miejsca na wypisanie tej liczby (domyślnie jest brane tyle miejsc ile potrzeba)
    • %03d oznacza, że liczba będzie wypisana na trzech miejscach i te nadmiarowe, niewykorzystane miejsca będą wypełnione zerami (domyślnie są spacje)

Oczywiście, jest całe mnóstwo innych placeholderów. Jeżeli jesteś bardziej zainteresowany tym tematem, zajrzyj do dokumentacji Javy.

Ostania linijka w pętli, po lekturze poprzedniego artykułu, jest już jasna. Program wygeneruje nam 200 plików graficznych, z których będziemy teraz tworzyli film – swego rodzaju animację poklatkową.

Tworzenie animacji

W programie do tworzenia animacji (klasa: pl.backlog.green.GenerateMovieFromPictures) najistotniejszy jest obiekt klasy org.opencv.videoio.VideoWriter:

VideoWriter videoWriter = new VideoWriter(
        outputPath,
        fourcc('F', 'M', 'P', '4'),
        fps,
        expectedSize,
        isColor);

Przyjrzyjmy się kolejnym argumentom konstruktora klasy org.opencv.videoio.VideoWriter:

  • outputPath – lokalizacja wynikowego pliku wideo
  • fourcc('F', 'M', 'P', '4') – jest to bardzo nieoczywiste określenie metody kodowania pliku wideo. FourCC jest dość starym standardem opisu formatu danych (zobacz na Wikipedii). Możesz podejrzeć pełną listę wszystkich formatów na tej stronie. W internecie jest wiele dyskusji na temat tych formatów. Na przykład tu możesz przeczytać, jak sprawdzić zainstalowane w systemie codeci
  • fps oznacza jak wiele obrazków ma się wyświetlać w ciągu sekundy – poeksperymentuj z wartościami tej zmiennej
  • expectedSize – to jest obiekt klasy org.opencv.core.Size, który określa rozmiar kadru wynikowego filmu. W programie wykorzystuję do określenia tego rozmiaru funkcję:
    public static Size getExpectedSize(String path) throws IOException {
        Path inputDirPath = Paths.get(path);
        String imagePath = Files.list(inputDirPath).findFirst().orElseThrow().toString();
        Mat image = imread(imagePath, IMREAD_COLOR);
        return image.size();
    }

    która:

    1. Tworzy obiekt klasy java.nio.file.Path, który zawiera opis lokalizacji katalogu z wejściowymi obrazkami
    2. Z listy wszystkich plików wewnątrz katalogu wybiera pierwszy z brzegu i zapamiętuje ścieżkę (jeżeli w katalogu nie ma żadnego pliku to rzuca wyjątkiem)
    3. Wczytuje obraz do obiektu klasy org.opencv.core.Mat
    4. Zwraca jego rozmiar
  • isColor – flaga logiczna, która sygnalizuje czy obrazek jest kolorowy, czy czarnobiały

Po utworzeniu obiektu videoWriter sprawdzamy, czy jest to prawidłowy obiekt. Czyli wykorzystujemy metodę isOpened, która zwraca false, gdy są jakieś problemy ze stworzonym obiektem. Metoda release zwalnia zasoby systemowe związane z tworzonym plikiem wideo – ich zwolnienie jest w dobrym tonie.

Następny fragment programu:

Files.list(Paths.get(dirPath))
        .map(Path::toString)
        .peek(System.out::println)
        .map(p -> Imgcodecs.imread(p, IMREAD_COLOR))
        .forEach(videoWriter::write);

wykorzystuje Stream API wprowadzone do Javy w wersji 1.8. Jest to bardzo ciekawe narzędzie. Moim zdaniem bardzo ułatwia czytanie kodu źródłowego. W standardowej Javie, można zaprogramować to samo używając pętli for:

List<Path> filesList = Files.list(Paths.get(dirPath)).collect(Collectors.toList());
for (Path filePath : filesList) {
    String filePathString = filePath.toString();
    System.out.println(filePathString);
    Mat image = Imgcodecs.imread(filePathString, IMREAD_COLOR);
    videoWriter.write(image);
}

Stream API jest przykładem programowania funkcyjnego w Javie. O programowaniu funkcyjnym i strumieniach chcę stworzyć osobny artykuł.

Finalny program

Łącząc ze sobą wszystko to, co napisałem dziś i w poprzednim wpisie otrzymujemy program, który

  1. Wczytuje serię obrazków
  2. Zmienia ich rozmiar do oczekiwanego
  3. Wczytuje grafikę ze znakiem wodnym
  4. Nanosi znak wodny na każdy obrazek
  5. Z obrazków tworzy film i zapisuje go na dysk
public class TimeLapseWithWatermark {
    public static Size getVideoSize(String path, int width) throws IOException {
        Path inputDirPath = Paths.get(path);
        String imagePath = Files.list(inputDirPath).findFirst().orElseThrow().toString();
        Mat image = imread(imagePath, IMREAD_COLOR);
        Size oldSize = image.size();
        if (oldSize.width > width) {
            double scale = ((double)width)/((double)oldSize.width);
            Size newSize = new Size(oldSize.width*scale, oldSize.height*scale);
            return newSize;
        } else {
            return oldSize;
        }
    }

    public static Mat addWatermark(Mat image, Mat watermark) {
        cvtColor(image, image, COLOR_BGR2BGRA);
        Mat transparentLayer = new Mat(image.rows(), image.cols(), CV_8UC4);
        Rect roi = new Rect(
                image.cols() - watermark.cols()-10,
                image.rows() - watermark.rows()-10,
                watermark.cols(),
                watermark.rows());
        watermark.copyTo(transparentLayer.submat(roi));
        addWeighted(image, 1, transparentLayer, 0.4, 0, image);
        cvtColor(image, image, COLOR_BGRA2BGR);
        return image;
    }

    public static Stream<Mat> addWatermark(Stream<Mat> images, Mat watermark) {
        return images.map(image -> addWatermark(image, watermark));
    }

    public static Mat readWatermark(String path, int width) {
        Mat watermark = imread(path, IMREAD_UNCHANGED);
        Size oldSize = watermark.size();
        double scale = ((double)width)/((double)oldSize.width);
        Size newSize = new Size(oldSize.width*scale, oldSize.height*scale);
        Imgproc.resize(watermark, watermark, newSize);
        return watermark;
    }

    public static void writeMovie(Stream<Mat> images, String outputPath, int fps, Size expectedSize) {
        VideoWriter videoWriter = new VideoWriter(
                outputPath,
                fourcc('F', 'M', 'P', '4'),
                fps,
                expectedSize,
                true);

        if(!videoWriter.isOpened()){
            videoWriter.release();
            throw new IllegalArgumentException(
                    "Video Writer Exception: VideoWriter not opened, check parameters.");
        }

        images.forEach(videoWriter::write);
        videoWriter.release();
    }

    public static Mat resize(Mat image, Size size) {
        Imgproc.resize(image, image, size);
        return image;
    }

    public static void createTimeLapse(String imagesDirPath, String watermarkPath, String outPath, int expectedWidth, int fps) throws IOException {
        Size size = getVideoSize(imagesDirPath, expectedWidth);
        Mat watermark = readWatermark(watermarkPath, (int) (expectedWidth*0.13));

        Stream<Mat> images = Files.list(Paths.get(imagesDirPath))
                .map(Path::toAbsolutePath)
                .map(Path::toString)
                .map(p -> imread(p))
                .map(i -> resize(i, size));
        writeMovie(addWatermark(images, watermark), outPath, fps, size);

    }

    public static void main(String[] args) throws IOException {
        System.loadLibrary(Core.NATIVE_LIBRARY_NAME);

        String imagesDirPath = args[0];
        String watermarkPath = args[1];
        String outputPath = args[2];
        int expectedSize = Integer.parseInt(args[3]);
        int fps = Integer.parseInt(args[4]);

        createTimeLapse(imagesDirPath, watermarkPath, outputPath, expectedSize, fps);
    }
}

Używając tego kodu źródłowego otrzymałem następujący film:

Jeżeli polubisz mój fan page na facebooku, będziesz powiadomiony o każdym następnym wpisie. A kolejne wpisy już wkrótce! Mam nadzieję, że bardziej systematycznie.

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Scroll to top