diff --git a/.dockerignore b/.dockerignore index 7fb3be5..9cbffac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ _install/ **.avi **.mp4 **.png +**.jpg diff --git a/.gitignore b/.gitignore index 3261b5b..16f1f19 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.mp4 _build/ _install/ +*/CACHEDIR.TAG diff --git a/.vscode/settings.json b/.vscode/settings.json index b9b0fe6..4498f08 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,11 @@ "files.associations": { "iostream": "cpp", "vector": "cpp", - "xstring": "cpp" + "xstring": "cpp", + "array": "cpp", + "istream": "cpp", + "tuple": "cpp", + "utility": "cpp" }, "editor.formatOnSave": true } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 038fbfa..66b3014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## v1.0.2 + +*Release date: 2023/08/29* + +* Only attempt to detect line starts/ends in rows with pure black at the edge. (commit 1199036) +* Fix rare bugs in the line-start gap extrapolation logic (commits e80e373 and b9c1527) +* Improve error messages, make them more informative and user-friendly +* Make parsing of framerate parameter more strict and print the framerate that is being used (and + where it is taken from, i.e. from commandline parameter or input file). +* Fix calculation of line-start values for other `colRange` values (commmit 0bd2627) + * Not relevant for users yet, but an important preparation for the commandline options + that are planned for allowing to change parameters like `colRange` +* refactor line gap interpolation. Interpolation and extrapolation are now performed by + two separate functions, resulting in much cleaner code (commit bdb7e95). +* The **Docker image** is now based on Debian 12 (bookworm) and uses OpenCV 4.6.0. Previously + Debian 11 (bullseye) was used with OpenCV 4.5.3. + ## v1.0.1 *Release date: 2023/04/09* diff --git a/Dockerfile b/Dockerfile index 11eef2c..4cbe2e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM debian:stable AS builder +FROM debian:12 AS builder LABEL org.opencontainers.image.authors="mail@robertnitsch.de" -LABEL org.opencontainers.image.version="1.0.1" +LABEL org.opencontainers.image.version="1.0.2" LABEL org.opencontainers.image.title="Fix horizontal shaking in digitized VHS videos" LABEL org.opencontainers.image.url="https://github.com/rsnitsch/vhs-deshaker" @@ -22,10 +22,10 @@ RUN cmake --install _build/ # ----------------------------------- FROM debian:stable -RUN apt-get update && apt-get -y upgrade && apt-get install -y libopencv-core4.5 \ - libopencv-highgui4.5 \ - libopencv-imgproc4.5 \ - libopencv-videoio4.5 \ +RUN apt-get update && apt-get -y upgrade && apt-get install -y libopencv-core406 \ + libopencv-highgui406 \ + libopencv-imgproc406 \ + libopencv-videoio406 \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /vhs-deshaker/_install/bin/vhs-deshaker /usr/bin/vhs-deshaker WORKDIR /videos diff --git a/README.md b/README.md index 4eadbaf..c5d2db2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,20 @@ -# vhs-deshaker +# vhs-deshaker This tool is for fixing a very specific horizontal shaking issue that affects some digitized VHS videos. +## Table of Contents + +- [Example](#example) +- [How does vhs-deshaker work?](#how-does-vhs-deshaker-work) +- [Install](#install) +- [Usage](#usage) +- [Build instructions](#build-instructions) +- [Docker image](#docker-image) + - [Docker troubleshooting: Input file cannot be opened](#docker-troubleshooting-input-file-cannot-be-opened) +- [See also](#see-also) + +## Example + Input example: https://user-images.githubusercontent.com/1746428/225921679-25f9ba88-bee2-4557-9181-5fd0df6a07b4.mp4 @@ -81,15 +94,29 @@ See BUILD.md. ## Docker image -You can also run vhs-deshaker via docker. I provide a docker image at ``rsnitsch/vhs-deshaker:latest``. +You can also run vhs-deshaker via docker. I provide a docker image at [`rsnitsch/vhs-deshaker`](https://hub.docker.com/r/rsnitsch/vhs-deshaker). Example command: docker run -it --rm -v "$(pwd):/videos" --user $(id -u):$(id -g) rsnitsch/vhs-deshaker:latest [] +### Docker troubleshooting: Input file cannot be opened + +If the input file cannot be opened, double-check that your input file is actually visible within docker. The option `-v "$(pwd):/videos"` +*should* map the current working directory (on your host system) inside the docker container at `/videos` (which is the working directory +of vhs-deshaker inside the docker container). + +You can use the following command to list the files visible inside the vhs-deshaker docker container at `/videos`: + + docker run --entrypoint ls -it --rm -v "$(pwd):/videos" --user $(id -u):$(id -g) rsnitsch/vhs-deshaker:latest -Al + +This executes `ls -Al` inside the docker container which lists the files in `/videos`. You should see the same contents as in your +current working directory on your host system. If not, then you have to find out why and fix this first. + ## See also Online threads discussing the issue: + - https://forum.videohelp.com/threads/394670-VHS-Horizontal-Stabilisation - https://forum.videohelp.com/threads/392186-Way-too-shakey-captured-VHS-video - https://forum.doom9.org/showthread.php?t=174756 diff --git a/TODOs.md b/TODOs.md new file mode 100644 index 0000000..57b194f --- /dev/null +++ b/TODOs.md @@ -0,0 +1,20 @@ +# TODOs + +## Features + +- add `--col-range` commandline option +- add `--framerate` commandline option (replacing the previously used *positional* framerate parameter) +- add commandline parameter to set `TARGET_LINE_START`. This is the regular/ideal width of the pure black + area at the left and right sides of the video frames. So it could get a new name like `PURE_BLACK_WIDTH` + and get a corresponding commandline option `--pure-black-width`. +- add `--min-segment-length` commandline option (parameter for `denoise_line_starts`) +- `draw_line_starts` should use a better visualization for negative line_starts. Idea: For a row with negative line_start value, fill up the pixels from the *opposite* side with a bright (e.g. red) color that + indicates the area where information is lost. Actually this idea should be expanded to all **extreme** line_start values. (big positive line_starts are also leading to data loss.) + +## Refactoring / Code cleanup + +- `line_ends` do not actually refer to end positions, instead they're just line_starts + determined by scanning from the end (right side) of the rows. this distinction should be made clearer + because I bet it will utterly confuse some people. +- `draw_line_starts` adds to the confusion because you can pass `line_ends` with an `x_offset` to draw + the edge at the right side of the image frame diff --git a/include/correct_frame.h b/include/correct_frame.h index 7076242..7f0b9ed 100644 --- a/include/correct_frame.h +++ b/include/correct_frame.h @@ -13,15 +13,16 @@ * enough to find the majority of line starts in the frame. For example, for mild cases of shaking/distortion a value of * 2 * PURE_BLACK_START_COLUMN is sufficient, where PURE_BLACK_START_COLUMN is the column * where the pure black starts in your digitized VHS video. - * @param grayBuffer A Mat that can be reused as buffer for the grayscale version of the input frame. + * @param grayBuffer1 A Mat that can be reused as buffer for the grayscale version of the input frame (left border). + * @param grayBuffer1 A Mat that can be reused as buffer for the grayscale version of the input frame (right border). * @param sobelBuffer1 A Mat that can be reused as buffer for the first Sobel-processed frame region (at left border). - * @param sobelBuffer2 A Mat that can be reused as buffer for the second Sobel-processed frame regions (at right border). + * @param sobelBuffer2 A Mat that can be reused as buffer for the second Sobel-processed frame region (at right border). * @param line_starts_buffer A vector that can be reused as buffer to store line starts. * @param line_ends_buffer A vector that can be reused as buffer to store line ends. * @param out The corrected output frame (BGR). */ -void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv::Mat &sobelBuffer1, cv::Mat &sobelBuffer2, - std::vector &line_starts_buffer, std::vector &line_ends_buffer, cv::Mat &out); +void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer1, cv::Mat &grayBuffer2, cv::Mat &sobelBuffer1, + cv::Mat &sobelBuffer2, std::vector &line_starts_buffer, std::vector &line_ends_buffer, cv::Mat &out); /** * Draw line starts into an image frame for debugging purposes. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 322bf2c..bd1dcab 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,9 @@ if(WIN32) get_target_property(opencv_world_dll_source_filepath opencv_world IMPORTED_LOCATION_RELEASE) install(FILES ${opencv_world_dll_source_filepath} DESTINATION bin) + get_target_property(opencv_world_dll_source_filepath opencv_world IMPORTED_LOCATION_DEBUG) + install(FILES ${opencv_world_dll_source_filepath} DESTINATION bin) + file(GLOB opencv_ffmpeg_dll "${OpenCV_DIR}/x64/vc16/bin/opencv_videoio_ffmpeg*.dll") install(FILES ${opencv_ffmpeg_dll} DESTINATION bin) endif() diff --git a/src/correct_frame.cpp b/src/correct_frame.cpp index 680a743..86ce178 100644 --- a/src/correct_frame.cpp +++ b/src/correct_frame.cpp @@ -9,31 +9,38 @@ using std::vector; // Internal helper methods. -void get_raw_line_starts(const cv::Mat &sobelX, vector &line_starts, int direction); +void get_raw_line_starts(const cv::Mat &gray, const cv::Mat &sobelX, vector &line_starts, int direction); void denoise_line_starts(vector &line_starts, vector &segment_sizes); void merge_line_starts_adv(const vector &line_starts1, const vector &line_starts2, vector &segment_sizes1, vector &segment_sizes2, vector &merged, int &merged_from_starts_count, int &merged_from_ends_count); +void fill_gaps_in_line_starts(vector &line_starts); +bool extrapolate_line_starts(vector &line_starts); void interpolate_line_starts(vector &line_starts); -const int MISSING = -1; +const int MISSING = INT_MIN; const int DIRECTION_LEFT_TO_RIGHT = 1; const int DIRECTION_RIGHT_TO_LEFT = -1; -void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv::Mat &sobelBuffer1, cv::Mat &sobelBuffer2, - vector &line_starts_buffer, vector &line_ends_buffer, cv::Mat &out) { +// TARGET_LINE_START is the expected(ideal) width of the +// left- and right-hand black borders in a decent digitized VHS video. +const int TARGET_LINE_START = 8; + +void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer1, cv::Mat &grayBuffer2, cv::Mat &sobelBuffer1, + cv::Mat &sobelBuffer2, vector &line_starts_buffer, vector &line_ends_buffer, cv::Mat &out) { out.create(input.size(), input.type()); - cv::Mat &gray = grayBuffer; - cv::cvtColor(input.colRange(0, colRange), gray, cv::COLOR_BGR2GRAY); - cv::Sobel(gray, sobelBuffer1, CV_32F, 1, 0); + cv::cvtColor(input.colRange(0, colRange), grayBuffer1, cv::COLOR_BGR2GRAY); + cv::Sobel(grayBuffer1, sobelBuffer1, CV_32F, 1, 0, 1); + assert(sobelBuffer1.cols == colRange); - cv::cvtColor(input.colRange(input.cols - colRange, input.cols), gray, cv::COLOR_BGR2GRAY); - cv::Sobel(gray, sobelBuffer2, CV_32F, 1, 0); + cv::cvtColor(input.colRange(input.cols - colRange, input.cols), grayBuffer2, cv::COLOR_BGR2GRAY); + cv::Sobel(grayBuffer2, sobelBuffer2, CV_32F, 1, 0, 1); + assert(sobelBuffer2.cols == colRange); vector &line_starts = line_starts_buffer; vector &line_ends = line_ends_buffer; - get_raw_line_starts(sobelBuffer1, line_starts, DIRECTION_LEFT_TO_RIGHT); - get_raw_line_starts(sobelBuffer2, line_ends, DIRECTION_RIGHT_TO_LEFT); + get_raw_line_starts(grayBuffer1, sobelBuffer1, line_starts, DIRECTION_LEFT_TO_RIGHT); + get_raw_line_starts(grayBuffer2, sobelBuffer2, line_ends, DIRECTION_RIGHT_TO_LEFT); auto line_starts_raw = line_starts; auto line_ends_raw = line_ends; @@ -51,8 +58,8 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: merged_from_ends_count); auto line_starts_merged = line_starts; - interpolate_line_starts(line_starts); - auto line_starts_interpolated = line_starts; + fill_gaps_in_line_starts(line_starts); + auto line_starts_gapfilled = line_starts; cv::Mat line_starts_mat(line_starts); cv::blur(line_starts_mat, line_starts_mat, cv::Size(1, 51)); @@ -70,11 +77,13 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: waitKey = true; #endif + int x_offset = input.cols - 2 * TARGET_LINE_START; + #ifdef ENABLE_VISUALIZATIONS // Raw line starts: green. cv::Mat debug_image_line_starts_raw = input.clone(); draw_line_starts(debug_image_line_starts_raw, line_starts_raw, color_for_line_starts, 0); - draw_line_starts(debug_image_line_starts_raw, line_ends_raw, color_for_line_starts, input.cols - colRange); + draw_line_starts(debug_image_line_starts_raw, line_ends_raw, color_for_line_starts, x_offset); cv::namedWindow("1 - line_starts_raw"); cv::imshow("1 - line_starts_raw", debug_image_line_starts_raw); waitKey = true; @@ -84,7 +93,7 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: // After denoising: red. cv::Mat debug_image_line_starts_after_denoising = input.clone(); draw_line_starts(debug_image_line_starts_after_denoising, line_starts_after_denoising, color_for_line_starts, 0); - draw_line_starts(debug_image_line_starts_after_denoising, line_ends_after_denoising, color_for_line_starts, input.cols - colRange); + draw_line_starts(debug_image_line_starts_after_denoising, line_ends_after_denoising, color_for_line_starts, x_offset); cv::namedWindow("2 - line_starts_after_denoising"); cv::imshow("2 - line_starts_after_denoising", debug_image_line_starts_after_denoising); waitKey = true; @@ -101,11 +110,11 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: #ifdef ENABLE_VISUALIZATIONS // After interpolating: cyan. - cv::Mat debug_image_line_starts_interpolated = input.clone(); - draw_line_starts(debug_image_line_starts_interpolated, line_starts_interpolated, cv::Vec3b(255, 0, 255), 0); - draw_line_starts(debug_image_line_starts_interpolated, line_starts_merged, color_for_line_starts, 0); - cv::namedWindow("4 - line_starts_interpolated"); - cv::imshow("4 - line_starts_interpolated", debug_image_line_starts_interpolated); + cv::Mat debug_image_line_starts_gapfilled = input.clone(); + draw_line_starts(debug_image_line_starts_gapfilled, line_starts_gapfilled, cv::Vec3b(255, 0, 255), 0); + draw_line_starts(debug_image_line_starts_gapfilled, line_starts_merged, color_for_line_starts, 0); + cv::namedWindow("4 - line_starts_gapfilled"); + cv::imshow("4 - line_starts_gapfilled", debug_image_line_starts_gapfilled); waitKey = true; #endif @@ -118,17 +127,8 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: waitKey = true; #endif -#ifdef ENABLE_VISUALIZATIONS - if (waitKey) { - cv::waitKey(); - } -#endif - // Use the line_start data obtained by the above code to shift the content of all rows of the frame - // such that each row begins at TARGET_LINE_START. TARGET_LINE_START is the expected (ideal) width of the - // left- and right-hand black borders in a decent digitized VHS video. - const int TARGET_LINE_START = 8; - + // such that each row begins at TARGET_LINE_START. for (int y = 0; y < input.rows; ++y) { cv::Mat input_row = input.row(y); cv::Mat output_row = out.row(y); @@ -160,13 +160,31 @@ void correct_frame(cv::Mat &input, const int colRange, cv::Mat &grayBuffer, cv:: } } } + +#ifdef ENABLE_VISUALIZATIONS + cv::namedWindow("6 - out"); + cv::imshow("6 - out", out); + waitKey = true; +#endif + +#ifdef ENABLE_VISUALIZATIONS + if (waitKey) { + cv::waitKey(); + } +#endif } void draw_line_starts(cv::Mat &img, const std::vector line_starts, const cv::Vec3b &color, int x_offset) { assert(img.type() == CV_8UC3); for (int y = 0; y < img.size().height; ++y) { if (line_starts.at(y) != MISSING) { - img.at(y, line_starts.at(y) + x_offset) = color; + if ((line_starts.at(y) + x_offset) < 0) { + // Negative line_start. Draw the entire row red. + // TODO: Better visualization of negative line_starts. + img.row(y) = cv::Vec3b(0, 0, 255); + } else { + img.at(y, line_starts.at(y) + x_offset) = color; + } } } } @@ -181,31 +199,40 @@ void draw_line_starts(cv::Mat &img, const std::vector line_starts, const cv * e.g. all lines would start at X = 8 and end at X = W - 8 (where 8 is the size of the black border to the left * and right). * - * @param sobelX must be a ROI that contains either the left-hand columns or right-hand columns of a video frame's horizontal - * image gradient + * @param gray must be a ROI that contains either the left-hand columns or right-hand columns of a video frame + * @param sobelX the horizontal image gradient of the gray ROI * @param direction indicates whether sobelX is based on the left-hand part of the video (use the constant DIRECTION_LEFT_TO_RIGHT) or * the right-hand part of the video (use constant DIRECTION_RIGHT_TO_LEFT). * @param line_starts the determined line starts are saved in this list, i.e. line_starts.size() == sobelX.rows. The line * start data may be incomplete, i.e. for some rows it may be impossible to determine the start * position. The respective missing items in line_starts get assigned the special constant MISSING. */ -void get_raw_line_starts(const cv::Mat &sobelX, vector &line_starts, int direction) { +void get_raw_line_starts(const cv::Mat &gray, const cv::Mat &sobelX, vector &line_starts, int direction) { assert(direction == DIRECTION_LEFT_TO_RIGHT || direction == DIRECTION_RIGHT_TO_LEFT); line_starts.resize(sobelX.rows, MISSING); int x_start, x_step, x_stop; - if (direction == 1) { + if (direction == DIRECTION_LEFT_TO_RIGHT) { x_step = 1; x_start = 0; x_stop = sobelX.cols; - } else { + } else if (direction == DIRECTION_RIGHT_TO_LEFT) { x_step = -1; x_start = sobelX.cols - 1; x_stop = -1; + } else { + assert(false); } + for (int y = 0; y < sobelX.rows; ++y) { line_starts[y] = MISSING; for (int x = x_start; x != x_stop; x += x_step) { + // If there is no pure black at the edge, skip this row. The line start/end + // cannot be determined. + if (x == x_start && gray.at(y, x) > 50) { + break; + } + // double dX = cv::norm(next_pixel - pixel); double dX = abs(sobelX.at(y, x)); @@ -213,7 +240,21 @@ void get_raw_line_starts(const cv::Mat &sobelX, vector &line_starts, int di #if 0 input.at(y, x) = cv::Vec3b(255, 255, 255); #endif + +#if 1 + if (direction == DIRECTION_LEFT_TO_RIGHT) { + line_starts[y] = x; + } else if (direction == DIRECTION_RIGHT_TO_LEFT) { + int reference_point = (sobelX.cols - 2 * TARGET_LINE_START); + line_starts[y] = x - reference_point; + } +#else + // Old code that only worked because my colRange value (15) happened to be close to 2 * TARGET_LINE_START. + // And TARGET_LINE_START equaled 8 for my videos. So 2 * TARGET_LINE_START was 16, which is almost the same + // as colRange (15). line_starts[y] = x; +#endif + break; } } @@ -304,80 +345,144 @@ void merge_line_starts_adv(const vector &line_starts1, const vector &l } /** - * Fills in gaps in the line_start data via linear interpolation (for inner gaps) and by extrapolating - * via nearest-known-value replication (for outer gaps at the top/bottom of a frame). + * Fills in gaps in the line_start data by nearest-known-value extrapolation (for outer gaps) + * and linear interpolation (for inner gaps). + */ +void fill_gaps_in_line_starts(vector &line_starts) { + bool no_outer_gaps_remain = extrapolate_line_starts(line_starts); + if (no_outer_gaps_remain) { + interpolate_line_starts(line_starts); + } + +#ifndef NDEBUG + // All gaps must have been filled! + // (Unless all line_starts are missing.) + bool allMissing = true; + bool someMissing = false; + for (int i = 0; i < line_starts.size(); ++i) { + if (line_starts.at(i) == MISSING) { + someMissing = true; + } else { + allMissing = false; + } + } + assert(allMissing || !someMissing); +#endif +} + +/** + * Fills in gaps in the line_start data at the beginning (top of frame) and end (bottom of frame) + * by nearest-known-value replication. * - * @todo This function could be split into a separate extrapolation (for outer gaps) and interpolation - * function (for inner gaps). By splitting these tasks the code would be easier to understand. + * @returns true if outer gaps were filled (or no outer gaps were present in the first place), + * returns false if there are gaps but they could not be filled because no line_starts are + * known at all to extrapolate from. + */ +bool extrapolate_line_starts(vector &line_starts) { + // Gap at the beginning? + if (line_starts.at(0) == MISSING) { + // Find nearest known value. + int first_known_value_index = -1; + for (int i = 0; i < line_starts.size(); ++i) { + if (line_starts.at(i) != MISSING) { + first_known_value_index = i; + break; + } + } + + if (first_known_value_index == -1) { + // No known values at all. Nothing to do. + return false; + } + + // Fill gap. + for (int i = first_known_value_index - 1; i >= 0; --i) { + line_starts.at(i) = line_starts.at(first_known_value_index); + } + } + + // Gap at the end? + if (line_starts.at(line_starts.size() - 1) == MISSING) { + // Find nearest known value. + int last_known_value_index = -1; + for (int i = line_starts.size() - 1; i >= 0; --i) { + if (line_starts.at(i) != MISSING) { + last_known_value_index = i; + break; + } + } + + if (last_known_value_index == -1) { + // No known values at all. Nothing to do. + return false; + } + + // Fill gap. + for (int i = last_known_value_index + 1; i < line_starts.size(); ++i) { + line_starts.at(i) = line_starts.at(last_known_value_index); + } + } + + return true; +} + +/** + * Fills inner gaps in the line_start data via linear interpolation. + * + * @throws std::logic_error if the line_starts data contains missing values at the beginning or end. Always use + * extrapolate_line_starts first. */ void interpolate_line_starts(vector &line_starts) { - // Constant used to indicate that we are currently searching for the beginning of a gap segment. + if (line_starts.at(0) == MISSING || line_starts.at(line_starts.size() - 1) == MISSING) { + throw std::logic_error( + "interpolate_line_starts cannot handle missing line_starts at the beginning or end. Always use extrapolate_line_starts first."); + } + const int SEARCHING_GAP = -2; int current_gap_begin = SEARCHING_GAP; + int current_gap_end = SEARCHING_GAP; + int known_value_before_gap = MISSING; + int known_value_after_gap = MISSING; for (int i = 0; i < line_starts.size(); ++i) { - if (current_gap_begin == SEARCHING_GAP && line_starts.at(i) == MISSING) { - // The beginning of a gap has been found. - current_gap_begin = i; - } else if (current_gap_begin != SEARCHING_GAP) { - if (line_starts.at(i) != MISSING || i == line_starts.size() - 1) { - // The current gap ends here, either because we found a line_start or because we reached the end of the - // line_starts vector (i.e. the bottom row of the frame). - - // For interpolation, we need to determine the last known line_start values from before the gap - // and from after the gap. - double line_start_after = MISSING; - double line_start_before = MISSING; - if (i != line_starts.size() - 1) { - line_start_after = line_starts.at(i); - } - if (current_gap_begin != 0) { - line_start_before = line_starts.at(current_gap_begin - 1); - } - - double interpolated = 0; - if (line_start_after != MISSING && line_start_before != MISSING) { - // Line_start values from before *and* after the current gap are known. - // This means it is an inner gap and we can interpolate the missing values. - int x0 = current_gap_begin - 1; - int x1 = i; - int y0 = line_start_before; - int y1 = line_start_after; - int x_range = i - (current_gap_begin - 1); - int y_range = line_start_after - line_start_before; - - for (int k = current_gap_begin; k < i; ++k) { - // Linear interpolation. - double x = k; - line_starts.at(k) = static_cast(round(y0 + (x - x0) / x_range * y_range)); - } - } else { - // This is an outer gap. We can only extrapolate. - if (line_start_after != MISSING) { - // The outer gap is towards the top of the frame. - interpolated = line_start_after; - } else if (line_start_before != MISSING) { - // The outer gap is towards the bottom of the frame. - interpolated = line_start_before; - } else { - // Neither a line_start value from before the gap is known nor a line_start value from after the gap. - // This means that no line_start data is available for this frame at all. Neither interpolation nor - // extrapolation is possible. - assert(i == line_starts.size() - 1); - return; - } - - // Extrapolate by simply replicating the closest known line_start value. - // Like BORDER_REPLICATE in OpenCV. - interpolated = round(interpolated); - int interpolated_int = static_cast(interpolated); - for (int k = current_gap_begin; k < i; ++k) { - line_starts.at(k) = interpolated_int; - } + if (current_gap_begin == SEARCHING_GAP) { + // Searching for the start of a gap. + if (line_starts.at(i) == MISSING) { + // The beginning of a gap has been found. + current_gap_begin = i; + } else { + known_value_before_gap = line_starts.at(i); + } + } else { + // Searching for the end of the current gap. + if (line_starts.at(i) != MISSING) { + current_gap_end = i - 1; + + assert(known_value_before_gap != MISSING); + known_value_after_gap = line_starts.at(i); + + // Linear interpolation. + int x0 = current_gap_begin - 1; + int x1 = i; + int y0 = known_value_before_gap; + int y1 = known_value_after_gap; + int x_range = x1 - x0; + int y_range = y1 - y0; + + for (int k = current_gap_begin; k <= current_gap_end; ++k) { + double x = k; + line_starts.at(k) = static_cast(round(y0 + (x - x0) / x_range * y_range)); } - // Search for new segment. + // Search next gaps. current_gap_begin = SEARCHING_GAP; } } } + +#ifndef NDEBUG + // All gaps must have been filled! + for (int i = 0; i < line_starts.size(); ++i) { + assert(line_starts.at(i) != MISSING); + } +#endif } diff --git a/src/main.cpp b/src/main.cpp index b9823e8..36ea793 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -12,7 +13,7 @@ using namespace std; // TODO: Add --col-range commandline parameter // TODO: Replace the positional framerate parameter with --framerate option int main(int argc, char *argv[]) { - cout << "vhs-deshaker 1.0.1" << endl << endl; + cout << "vhs-deshaker 1.0.2" << endl << endl; if (argc != 3 && argc != 4) { cerr << "Usage: vhs-deshaker []" << endl; return 1; @@ -20,13 +21,46 @@ int main(int argc, char *argv[]) { string input_file = argv[1]; string output_file = argv[2]; - double framerate = (argc == 4) ? stod(argv[3]) : -1; + double framerate = -1; + if (argc == 4) { + try { + size_t pos = -1; + framerate = stod(argv[3], &pos); + + // Make sure that the entire string is parsed as a valid number. + if (pos != strlen(argv[3])) { + cerr << "ERROR: Invalid framerate (not a number): " << argv[3] << endl; + return 1; + } + + if (framerate <= 0) { + cerr << "ERROR: Invalid framerate (must be a positive number)" << endl; + return 1; + } + } catch (const invalid_argument &e) { + cerr << "ERROR: Invalid framerate (not a number): " << e.what() << endl; + return 1; + } catch (const out_of_range &e) { + cerr << "ERROR: Invalid framerate (number is out of range): " << e.what() << endl; + return 1; + } + } if (input_file == output_file) { cerr << "ERROR: Input file matches output file." << endl; return 1; } + // Check if the input file exists and can be opened. + { + ifstream input_file_stream(input_file); + if (!input_file_stream.good()) { + cerr << "ERROR: Input file cannot be opened." << endl; + return 1; + } + input_file_stream.close(); + } + cout << "Processing file " << input_file << " ..." << endl; VideoCapture videoCapture(input_file); if (!videoCapture.isOpened()) { @@ -37,8 +71,15 @@ int main(int argc, char *argv[]) { double fps = -1; if (framerate <= 0) { fps = videoCapture.get(CAP_PROP_FPS); + if (fps <= 0) { + cerr << "Could not get framerate from input file. Please provide a framerate manually." << endl; + return 1; + } + + cout << "Using same framerate as in input file: " << fps << endl; } else { fps = framerate; + cout << "Using the user-specified framerate: " << fps << endl; } int fourcc = VideoWriter::fourcc('H', 'F', 'Y', 'U'); diff --git a/src/process_single_threaded.cpp b/src/process_single_threaded.cpp index df746ee..24a633c 100644 --- a/src/process_single_threaded.cpp +++ b/src/process_single_threaded.cpp @@ -15,7 +15,7 @@ void process_single_threaded(cv::VideoCapture &videoCapture, cv::VideoWriter &videoWriter, const int colRange) { int i = 0; int frame_count = videoCapture.get(cv::CAP_PROP_FRAME_COUNT); - cv::Mat img, corrected, grayBuffer, sobelBuffer1, sobelBuffer2; + cv::Mat img, corrected, grayBuffer1, grayBuffer2, sobelBuffer1, sobelBuffer2; std::vector line_starts, line_ends; while (videoCapture.grab()) { videoCapture.retrieve(img); @@ -28,7 +28,7 @@ void process_single_threaded(cv::VideoCapture &videoCapture, cv::VideoWriter &vi cv::putText(img, std::to_string(i), cv::Point(img.cols / 2, 200), cv::FONT_HERSHEY_SIMPLEX, 5, cv::Scalar(255, 255, 255), 3, cv::LINE_AA); #endif - correct_frame(img, colRange, grayBuffer, sobelBuffer1, sobelBuffer2, line_starts, line_ends, corrected); + correct_frame(img, colRange, grayBuffer1, grayBuffer2, sobelBuffer1, sobelBuffer2, line_starts, line_ends, corrected); #ifdef ENABLE_DEBUGGING cv::namedWindow("Input");