├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── gradle.xml ├── jarRepositories.xml └── misc.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── dan │ │ └── videostab │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── dan │ │ │ └── videostab │ │ │ ├── AppFragment.kt │ │ │ ├── BusyDialog.kt │ │ │ ├── FramesInput.kt │ │ │ ├── ImagesAsVideo.kt │ │ │ ├── ImagesFramesInput.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainFragment.kt │ │ │ ├── MaskEditFragment.kt │ │ │ ├── OutputParams.kt │ │ │ ├── Settings.kt │ │ │ ├── SettingsFragment.kt │ │ │ ├── TmpFiles.kt │ │ │ ├── TouchImageView.kt │ │ │ ├── Trajectory.kt │ │ │ ├── VideoEncoder.kt │ │ │ ├── VideoFramesInput.kt │ │ │ └── VideoTools.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── busy_dialog.xml │ │ ├── main_fragment.xml │ │ ├── mask_edit_fragment.xml │ │ └── settings_fragment.xml │ │ ├── menu │ │ └── app_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── dan │ └── videostab │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── opencv ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── aidl │ └── org │ │ └── opencv │ │ └── engine │ │ └── OpenCVEngineInterface.aidl │ ├── java │ └── org │ │ └── opencv │ │ ├── android │ │ ├── AsyncServiceHelper.java │ │ ├── BaseLoaderCallback.java │ │ ├── Camera2Renderer.java │ │ ├── CameraActivity.java │ │ ├── CameraBridgeViewBase.java │ │ ├── CameraGLRendererBase.java │ │ ├── CameraGLSurfaceView.java │ │ ├── CameraRenderer.java │ │ ├── FpsMeter.java │ │ ├── InstallCallbackInterface.java │ │ ├── JavaCamera2View.java │ │ ├── JavaCameraView.java │ │ ├── LoaderCallbackInterface.java │ │ ├── OpenCVLoader.java │ │ ├── StaticHelper.java │ │ └── Utils.java │ │ ├── calib3d │ │ ├── Calib3d.java │ │ ├── StereoBM.java │ │ ├── StereoMatcher.java │ │ ├── StereoSGBM.java │ │ └── UsacParams.java │ │ ├── core │ │ ├── Algorithm.java │ │ ├── Core.java │ │ ├── CvException.java │ │ ├── CvType.java │ │ ├── DMatch.java │ │ ├── KeyPoint.java │ │ ├── Mat.java │ │ ├── MatAt.kt │ │ ├── MatMatMul.kt │ │ ├── MatOfByte.java │ │ ├── MatOfDMatch.java │ │ ├── MatOfDouble.java │ │ ├── MatOfFloat.java │ │ ├── MatOfFloat4.java │ │ ├── MatOfFloat6.java │ │ ├── MatOfInt.java │ │ ├── MatOfInt4.java │ │ ├── MatOfKeyPoint.java │ │ ├── MatOfPoint.java │ │ ├── MatOfPoint2f.java │ │ ├── MatOfPoint3.java │ │ ├── MatOfPoint3f.java │ │ ├── MatOfRect.java │ │ ├── MatOfRect2d.java │ │ ├── MatOfRotatedRect.java │ │ ├── Point.java │ │ ├── Point3.java │ │ ├── Range.java │ │ ├── Rect.java │ │ ├── Rect2d.java │ │ ├── RotatedRect.java │ │ ├── Scalar.java │ │ ├── Size.java │ │ ├── TermCriteria.java │ │ └── TickMeter.java │ │ ├── engine │ │ └── OpenCVEngineInterface.aidl │ │ ├── features2d │ │ ├── AKAZE.java │ │ ├── AffineFeature.java │ │ ├── AgastFeatureDetector.java │ │ ├── BFMatcher.java │ │ ├── BOWImgDescriptorExtractor.java │ │ ├── BOWKMeansTrainer.java │ │ ├── BOWTrainer.java │ │ ├── BRISK.java │ │ ├── DescriptorMatcher.java │ │ ├── FastFeatureDetector.java │ │ ├── Feature2D.java │ │ ├── Features2d.java │ │ ├── FlannBasedMatcher.java │ │ ├── GFTTDetector.java │ │ ├── KAZE.java │ │ ├── MSER.java │ │ ├── ORB.java │ │ ├── SIFT.java │ │ ├── SimpleBlobDetector.java │ │ └── SimpleBlobDetector_Params.java │ │ ├── imgcodecs │ │ └── Imgcodecs.java │ │ ├── imgproc │ │ ├── CLAHE.java │ │ ├── GeneralizedHough.java │ │ ├── GeneralizedHoughBallard.java │ │ ├── GeneralizedHoughGuil.java │ │ ├── Imgproc.java │ │ ├── IntelligentScissorsMB.java │ │ ├── LineSegmentDetector.java │ │ ├── Moments.java │ │ └── Subdiv2D.java │ │ ├── utils │ │ └── Converters.java │ │ ├── video │ │ ├── BackgroundSubtractor.java │ │ ├── BackgroundSubtractorKNN.java │ │ ├── BackgroundSubtractorMOG2.java │ │ ├── DISOpticalFlow.java │ │ ├── DenseOpticalFlow.java │ │ ├── FarnebackOpticalFlow.java │ │ ├── KalmanFilter.java │ │ ├── SparseOpticalFlow.java │ │ ├── SparsePyrLKOpticalFlow.java │ │ ├── Tracker.java │ │ ├── TrackerDaSiamRPN.java │ │ ├── TrackerDaSiamRPN_Params.java │ │ ├── TrackerGOTURN.java │ │ ├── TrackerGOTURN_Params.java │ │ ├── TrackerMIL.java │ │ ├── TrackerMIL_Params.java │ │ ├── VariationalRefinement.java │ │ └── Video.java │ │ └── videoio │ │ ├── VideoCapture.java │ │ ├── VideoWriter.java │ │ └── Videoio.java │ ├── jniLibs │ └── arm64-v8a │ │ ├── libc++_shared.so │ │ └── libopencv_java4.so │ └── res │ └── values │ └── attrs.xml ├── python └── stab.py ├── sample ├── generic.mp4 ├── h-pan.mp4 ├── mask.mp4 ├── no_mask.mp4 ├── original-vs-generic.gif ├── original-vs-h-pan.gif ├── original-vs-pan.gif ├── original-vs-still.gif ├── original-vs-v-pan.gif ├── original.mp4 ├── original_for_mask.mp4 ├── original_vs_no_mask_vs_mask.gif ├── pan.mp4 ├── still.mp4 └── v-pan.mp4 ├── screenshot ├── edit-mask.jpg ├── input-info.jpg ├── main-screen.jpg ├── media.jpg ├── parameters.jpg └── toolbar.jpg └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | /.idea/vcs.xml 11 | /.idea/codeStyles/* 12 | .DS_Store 13 | /build 14 | /captures 15 | .externalNativeBuild 16 | .cxx 17 | local.properties 18 | /app/keystore.config 19 | /app/release/output-metadata.json 20 | /app/release/app-release.apk 21 | /opencv/build 22 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VideoStab 2 | 3 | A simple video stabilisation for Android, based on: 4 | * https://learnopencv.com/video-stabilization-using-point-feature-matching-in-opencv/ 5 | * https://github.com/spmallick/learnopencv/tree/master/VideoStabilization 6 | 7 | # How it works 8 | 9 | ## Analyse step 10 | 11 | It tries to detect the transformations between two consecutive frames: transition (X and Y) and rotation. 12 | Using this values it calculates a trajectory: for each frame it calculates the transition and rotation compared to the first frame. 13 | 14 | ## Stabilisation 15 | 16 | Using the trajectory, for each axe (X, Y, rotation) it will apply one of this algorithms: 17 | * none: keep values unchanged 18 | * reverse: tries to apply the reverse changes to put the frame in the same "position" as the first one 19 | * moving average: it will smooth the changes 20 | * distribute: it will evenly distribute the change between the first and the last frame (panning) 21 | 22 | Algorithm | X transformation | Y transformation | Rotation transformation 23 | -- | -- | -- | -- 24 | Generic | moving average | moving average | moving average 25 | Generic (B) | moving average | moving average | reverse 26 | Still | reverse | reverse | reverse 27 | Horizontal panning | distribute | reverse | reverse 28 | Horizontal panning (B) | distribute | reverse | moving average 29 | Vertical panning | reverse | distribute | reverse 30 | Vertical panning (B) | reverse | distribute | moving average 31 | Panning | distribute | distribute | reverse 32 | Panning (B) | distribute | distribute | moving average 33 | No rotation | none | none | reverse 34 | 35 | # Interface 36 | 37 | ![](screenshot/main-screen.jpg) 38 | ## Toolbar 39 | 40 | ![](screenshot/toolbar.jpg) 41 | 42 | In order; 43 | * Open a video file 44 | * Save the current stabilized video 45 | * Settings 46 | * ... allow to open a series of images or an images folder (that will be considered as a video) 47 | 48 | ## Input video informations 49 | 50 | ![](screenshot/input-info.jpg) 51 | * Resolution 52 | * Auto-detected FPS. NOTE: can be wrong in some cases 53 | * File name 54 | 55 | ## Stabilisation parameters 56 | 57 | ![](screenshot/parameters.jpg) 58 | 59 | * Algorithm: see "How it works" section for more details 60 | * Strength: seconds to be used for moving average window (1, 2, 3 or 4 seconds) 61 | * Crop: because the frames can be moved and rorated you can have black regions. This can be cropped (Auto, 0%, 5%, 10%) 62 | * FPS: you can for a specific FPS if the auto detection failes 63 | 64 | ## Media 65 | 66 | ![](screenshot/media.jpg) 67 | 68 | In order: 69 | * Play original video 70 | * Play stabilized video (it will apply the stabilisation if needed) 71 | * Stop the player 72 | * Edit stabilisation mask (used only for "Still" algorithm) 73 | 74 | ## Mask 75 | 76 | Some video have big moving element (like big clouds) that can false frame alignemnt. 77 | 78 | This screen allow to draw a mask for the first frame that specify items that suppose to be still. 79 | 80 | ![](screenshot/edit-mask.jpg) 81 | 82 | The light part is the mask. 83 | You can clear the mask, fill (all pixels are used) and you can draw / erase. 84 | To draw / erase you must press and keep hold one of the buttons and with another edit the mask. 85 | 86 | See thre result: original (left), still without mask (middle) and still with the mask (right). 87 | 88 | ![](sample/original_vs_no_mask_vs_mask.gif) 89 | 90 | # Examples 91 | 92 | See the original vs stabilized video. 93 | 94 | ## Original vs Generic 95 | 96 | ![](sample/original-vs-generic.gif) 97 | 98 | ## Original vs Still 99 | 100 | ![](sample/original-vs-still.gif) 101 | 102 | ## Original vs Horizontal panning 103 | 104 | ![](sample/original-vs-h-pan.gif) 105 | 106 | ## Original vs Vertical panning 107 | 108 | ![](sample/original-vs-v-pan.gif) 109 | 110 | ## Original vs Panning 111 | 112 | ![](sample/original-vs-pan.gif) 113 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | signingConfigs { 8 | 'default' { 9 | File keystoreConfigFile = file('keystore.config') 10 | if (keystoreConfigFile.exists()) { 11 | Properties keystoreProps = new Properties() 12 | keystoreProps.load(new FileInputStream(file('keystore.config'))) 13 | 14 | keyAlias keystoreProps['keyAlias'] 15 | keyPassword keystoreProps['keyPassword'] 16 | storePassword keystoreProps['storePassword'] 17 | storeFile file(keystoreProps['storePath']) 18 | } 19 | } 20 | } 21 | 22 | compileSdkVersion 30 23 | buildToolsVersion "30.0.2" 24 | 25 | defaultConfig { 26 | applicationId "com.dan.videostab" 27 | minSdkVersion 28 28 | targetSdkVersion 30 29 | versionCode 9 30 | versionName '1.9' 31 | 32 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 33 | } 34 | 35 | buildTypes { 36 | debug { 37 | if (file('keystore.config').exists()) { 38 | signingConfig signingConfigs.'default' 39 | } 40 | } 41 | release { 42 | minifyEnabled true 43 | shrinkResources true 44 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 45 | } 46 | } 47 | buildFeatures { 48 | dataBinding true 49 | } 50 | kotlinOptions { 51 | jvmTarget = '1.8' 52 | } 53 | } 54 | 55 | dependencies { 56 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 57 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" 58 | implementation 'androidx.core:core-ktx:1.6.0' 59 | implementation 'androidx.appcompat:appcompat:1.3.1' 60 | implementation 'com.google.android.material:material:1.4.0' 61 | implementation 'androidx.constraintlayout:constraintlayout:2.1.0' 62 | implementation project(path: ':opencv') 63 | testImplementation 'junit:junit:4.13.2' 64 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 65 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 66 | implementation "androidx.documentfile:documentfile:1.0.1" 67 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2-native-mt" 68 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9" 69 | } 70 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -keepclassmembers class com.dan.videostab.Settings { 24 | public *; 25 | } 26 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/dan/videostab/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.dan.videostab", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danopdev/VideoStab/da3e7aa6bc0df4a1f01cda4d8e495a32b0695d0a/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/AppFragment.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import androidx.fragment.app.Fragment 4 | 5 | open class AppFragment(val activity: MainActivity) : Fragment() { 6 | 7 | val settings = activity.settings 8 | 9 | fun runOnUiThread(action: ()->Unit) { 10 | activity.runOnUiThread(action) 11 | } 12 | 13 | open fun onBack(homeButton: Boolean) { 14 | } 15 | 16 | fun showToast(message: String) { 17 | runOnUiThread { 18 | activity.showToast(message) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/BusyDialog.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import android.os.Bundle 4 | import android.os.Looper 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.fragment.app.DialogFragment 9 | import com.dan.videostab.databinding.BusyDialogBinding 10 | 11 | class BusyDialog( private var message: String, private var progress: Int, private var total: Int): DialogFragment() { 12 | 13 | companion object { 14 | private const val FRAGMENT_TAG = "busy" 15 | private var currentDialog: BusyDialog? = null 16 | private lateinit var activity: MainActivity 17 | 18 | fun create(activity_: MainActivity) { 19 | activity = activity_ 20 | } 21 | 22 | private fun runSafe( callback: ()->Unit ) { 23 | if (Looper.getMainLooper().isCurrentThread) { 24 | callback() 25 | } else { 26 | activity.runOnUiThread { 27 | callback() 28 | } 29 | } 30 | } 31 | 32 | fun show(message: String, progress: Int = -1, total: Int = -1) { 33 | runSafe { 34 | if (null == currentDialog) { 35 | val dialog = BusyDialog(message, progress, total) 36 | dialog.isCancelable = false 37 | dialog.show(activity.supportFragmentManager, FRAGMENT_TAG) 38 | currentDialog = dialog 39 | } else { 40 | currentDialog?.update(message, progress, total) 41 | } 42 | } 43 | } 44 | 45 | fun dismiss() { 46 | runSafe { 47 | currentDialog?.dismiss() 48 | currentDialog = null 49 | } 50 | } 51 | 52 | fun showCancel() { 53 | runSafe { 54 | currentDialog?.showCancel() 55 | } 56 | } 57 | 58 | fun isCanceled(): Boolean { 59 | return currentDialog?._isCanceled ?: false 60 | } 61 | } 62 | 63 | private var _binding: BusyDialogBinding? = null 64 | private var _isCanceled = false 65 | private var _showCancel = false 66 | 67 | fun showCancel() { 68 | _showCancel = true 69 | _binding?.let { 70 | it.buttonCancel.visibility = View.VISIBLE 71 | } 72 | } 73 | 74 | fun update(message: String, progress: Int = -1, total: Int = -1) { 75 | this.message = message 76 | this.progress = progress 77 | this.total = total 78 | update() 79 | } 80 | 81 | private fun update() { 82 | val infinite = progress < 0 || total <= 0 || progress > total 83 | val title = if (progress < 0) message else "$message ($progress)" 84 | _binding?.let { 85 | it.textBusyMessage.text = title 86 | if (infinite) { 87 | it.progressBar.isIndeterminate = true 88 | } else { 89 | it.progressBar.progress = 0 90 | it.progressBar.max = total 91 | it.progressBar.progress = progress 92 | it.progressBar.isIndeterminate = false 93 | } 94 | } 95 | } 96 | 97 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 98 | val binding = BusyDialogBinding.inflate( inflater ) 99 | binding.textBusyMessage.text = message 100 | this._binding = binding 101 | 102 | binding.buttonCancel.setOnClickListener { 103 | _isCanceled = true 104 | binding.buttonCancel.isEnabled = false 105 | } 106 | 107 | if (_showCancel) { 108 | binding.buttonCancel.visibility = View.VISIBLE 109 | } 110 | 111 | update() 112 | return binding.root 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/FramesInput.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import android.net.Uri 4 | import org.opencv.core.Mat 5 | 6 | abstract class FramesInput { 7 | companion object { 8 | fun fixName(original: String?): String { 9 | if (null == original) return "unknown" 10 | return original.split('.')[0] 11 | } 12 | } 13 | 14 | abstract val fps: Int 15 | abstract val name: String 16 | abstract val width: Int 17 | abstract val height: Int 18 | abstract val imageUris: List? 19 | abstract val videoUri: Uri? 20 | abstract val size: Int 21 | 22 | abstract fun forEachFrame(callback: (Int, Int, Mat)->Boolean) 23 | 24 | fun firstFrame(): Mat { 25 | var firstFrame = Mat() 26 | forEachFrame { _, _, frame -> 27 | firstFrame = frame 28 | false 29 | } 30 | return firstFrame 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/ImagesAsVideo.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.net.Uri 6 | import android.util.AttributeSet 7 | import android.view.View 8 | import java.util.* 9 | import kotlin.concurrent.timer 10 | 11 | 12 | 13 | open class ImagesAsVideo @JvmOverloads constructor( 14 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 15 | ) : View(context, attrs, defStyleAttr) { 16 | private var _images: List? = null 17 | private var _fps: Int = 5 18 | private var _index: Int = 0 19 | private var _bitmap: Bitmap? = null 20 | private val _rect = Rect() 21 | private var _timer: Timer? = null 22 | private var _bitmapDisplayed = false 23 | 24 | fun setImages(images: List?, fps: Int = 0) { 25 | stopTimer() 26 | _images = images 27 | _index = 0 28 | _fps = when { 29 | fps < 5 -> 5 30 | fps > 30 -> 30 31 | else -> fps 32 | } 33 | updateBitmap() 34 | } 35 | 36 | fun play() { 37 | _index = 0 38 | updateBitmap() 39 | startTimer() 40 | } 41 | 42 | fun stop() { 43 | stopTimer() 44 | _index = 0 45 | updateBitmap() 46 | } 47 | 48 | private fun startTimer() { 49 | val period = 1000L / _fps 50 | _timer = timer(null, false, period, period) { 51 | if (_bitmapDisplayed) { 52 | nextFrame() 53 | } 54 | } 55 | } 56 | 57 | private fun stopTimer() { 58 | _timer?.cancel() 59 | _timer = null 60 | } 61 | 62 | private fun nextFrame() { 63 | val images = _images 64 | val index = _index 65 | 66 | if (null != images && images.isNotEmpty() && index >= 0 && index < (images.size-1)) { 67 | _index++ 68 | updateBitmap() 69 | } else { 70 | stopTimer() 71 | } 72 | } 73 | 74 | private fun updateBitmap() { 75 | _bitmap = null 76 | 77 | val images = _images 78 | val context = this.context 79 | if (null != context && null != images && images.isNotEmpty()) { 80 | val index = _index 81 | if (index >= 0 && index < images.size) { 82 | try { 83 | val inputStream = context.contentResolver.openInputStream(images[index]) 84 | if (null != inputStream) { 85 | _bitmap = BitmapFactory.decodeStream(inputStream) 86 | inputStream.close() 87 | } 88 | } catch (e: Exception) { 89 | e.printStackTrace() 90 | } 91 | } 92 | } 93 | 94 | _bitmapDisplayed = false 95 | invalidate() 96 | } 97 | 98 | override fun onDraw(canvas: Canvas?) { 99 | super.onDraw(canvas) 100 | 101 | _bitmapDisplayed = true 102 | if (null == canvas) return 103 | 104 | canvas.drawRGB(0,0,0) 105 | 106 | val bitmap = _bitmap ?: return 107 | 108 | var outputWidth = width 109 | var outputHeight = width * bitmap.height / bitmap.width 110 | 111 | if (outputHeight > height) { 112 | outputHeight = height 113 | outputWidth = height * bitmap.width / bitmap.height 114 | } 115 | 116 | val x = (width - outputWidth) / 2 117 | val y = (height - outputHeight) / 2 118 | _rect.set(x, y, x + outputWidth, y + outputHeight) 119 | 120 | canvas.drawBitmap(bitmap, null, _rect, null) 121 | } 122 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/OutputParams.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | class OutputParams { 4 | companion object { 5 | const val VALUE_UNKNOWN = Int.MIN_VALUE 6 | 7 | const val COMPARE_NOT_CHANGED = 0 8 | const val COMPARE_CHANGED_ONLY_FPS = 1 9 | const val COMPARE_CHANGED = 2 10 | 11 | const val KEY_FPS = "FPS" 12 | const val KEY_ALGORITHM = "ALGORITHM" 13 | const val KEY_STRENGTH = "STRENGTH" 14 | const val KEY_CROP = "CROP" 15 | const val KEY_ALIGN = "ALIGN" 16 | 17 | private fun compare(a: Map, b: Map): Int { 18 | var fpsChanged = false 19 | 20 | a.forEach { (key, value) -> 21 | if (value != b.getOrDefault(key, VALUE_UNKNOWN)) { 22 | if (KEY_FPS == key) { 23 | fpsChanged = true 24 | } else { 25 | return COMPARE_CHANGED 26 | } 27 | } 28 | } 29 | 30 | return if (fpsChanged) COMPARE_CHANGED_ONLY_FPS else COMPARE_NOT_CHANGED 31 | } 32 | } 33 | 34 | private val _params = mutableMapOf() 35 | 36 | fun set(key: String, value: Int) { 37 | _params[key] = value 38 | } 39 | 40 | fun get(key: String): Int { 41 | return _params.getOrDefault(key, VALUE_UNKNOWN) 42 | } 43 | 44 | fun compareWith(other: OutputParams?): Int { 45 | if (null == other) return COMPARE_CHANGED 46 | 47 | val compare1 = compare(_params, other._params) 48 | if (COMPARE_CHANGED == compare1) return COMPARE_CHANGED 49 | 50 | val compare2 = compare(other._params, _params) 51 | if (COMPARE_CHANGED == compare2) return COMPARE_CHANGED 52 | 53 | if (COMPARE_CHANGED_ONLY_FPS == compare1 || COMPARE_CHANGED_ONLY_FPS == compare2) return COMPARE_CHANGED_ONLY_FPS 54 | return COMPARE_NOT_CHANGED 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/Settings.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.dan.videostab 3 | 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.os.Environment 7 | import java.io.File 8 | import kotlin.reflect.KMutableProperty 9 | import kotlin.reflect.KVisibility 10 | import kotlin.reflect.full.createType 11 | import kotlin.reflect.full.declaredMemberProperties 12 | 13 | /** 14 | Settings: all public var fields will be saved 15 | */ 16 | class Settings( private val activity: Activity) { 17 | 18 | companion object { 19 | const val ALGORITHM_GENERIC = 0 20 | const val ALGORITHM_GENERIC_B = 1 21 | const val ALGORITHM_STILL = 2 22 | const val ALGORITHM_HORIZONTAL_PANNING = 3 23 | const val ALGORITHM_HORIZONTAL_PANNING_B = 4 24 | const val ALGORITHM_VERTICAL_PANNING = 5 25 | const val ALGORITHM_VERTICAL_PANNING_B = 6 26 | const val ALGORITHM_PANNING = 7 27 | const val ALGORITHM_PANNING_B = 8 28 | const val ALGORITHM_NO_ROTATION = 9 29 | 30 | val SAVE_FOLDER = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), "VideoStab") 31 | } 32 | 33 | var algorithm: Int = ALGORITHM_GENERIC 34 | var strength: Int = 1 35 | var encodeH265 = true 36 | var keepAudio = false 37 | 38 | init { 39 | loadProperties() 40 | } 41 | 42 | 43 | private fun forEachSettingProperty( listener: (KMutableProperty<*>)->Unit ) { 44 | for( member in this::class.declaredMemberProperties ) { 45 | if (member.visibility == KVisibility.PUBLIC && member is KMutableProperty<*>) { 46 | listener.invoke(member) 47 | } 48 | } 49 | } 50 | 51 | private fun loadProperties() { 52 | val preferences = activity.getPreferences(Context.MODE_PRIVATE) 53 | 54 | forEachSettingProperty { property -> 55 | when( property.returnType ) { 56 | Boolean::class.createType() -> property.setter.call( this, preferences.getBoolean( property.name, property.getter.call(this) as Boolean ) ) 57 | Int::class.createType() -> property.setter.call( this, preferences.getInt( property.name, property.getter.call(this) as Int ) ) 58 | Long::class.createType() -> property.setter.call( this, preferences.getLong( property.name, property.getter.call(this) as Long ) ) 59 | Float::class.createType() -> property.setter.call( this, preferences.getFloat( property.name, property.getter.call(this) as Float ) ) 60 | String::class.createType() -> property.setter.call( this, preferences.getString( property.name, property.getter.call(this) as String ) ) 61 | } 62 | } 63 | } 64 | 65 | fun saveProperties() { 66 | val preferences = activity.getPreferences(Context.MODE_PRIVATE) 67 | val editor = preferences.edit() 68 | 69 | forEachSettingProperty { property -> 70 | when( property.returnType ) { 71 | Boolean::class.createType() -> editor.putBoolean( property.name, property.getter.call(this) as Boolean ) 72 | Int::class.createType() -> editor.putInt( property.name, property.getter.call(this) as Int ) 73 | Long::class.createType() -> editor.putLong( property.name, property.getter.call(this) as Long ) 74 | Float::class.createType() -> editor.putFloat( property.name, property.getter.call(this) as Float ) 75 | String::class.createType() -> editor.putString( property.name, property.getter.call(this) as String ) 76 | } 77 | } 78 | 79 | editor.apply() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import com.dan.videostab.databinding.SettingsFragmentBinding 8 | 9 | 10 | class SettingsFragment(activity: MainActivity ) : AppFragment(activity) { 11 | 12 | companion object { 13 | fun show(activity: MainActivity ) { 14 | activity.pushView("Settings", SettingsFragment( activity )) 15 | } 16 | } 17 | 18 | private lateinit var binding: SettingsFragmentBinding 19 | 20 | private fun updateView() { 21 | binding.switchEncode265.text = if (binding.switchEncode265.isChecked) "Encoder H265/HEVC" else "Encoder H264 (legacy)" 22 | binding.switchKeepAudio.text = if (binding.switchKeepAudio.isChecked) "Keep audio" else "Don't keep audio" 23 | } 24 | 25 | override fun onBack(homeButton: Boolean) { 26 | settings.encodeH265 = binding.switchEncode265.isChecked 27 | settings.keepAudio = binding.switchKeepAudio.isChecked 28 | settings.saveProperties() 29 | } 30 | 31 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 32 | binding = SettingsFragmentBinding.inflate( inflater ) 33 | 34 | binding.switchEncode265.isChecked = settings.encodeH265 35 | binding.switchKeepAudio.isChecked = settings.keepAudio 36 | 37 | binding.switchEncode265.setOnCheckedChangeListener { _, _ -> updateView() } 38 | binding.switchKeepAudio.setOnCheckedChangeListener { _, _ -> updateView() } 39 | 40 | updateView() 41 | 42 | return binding.root 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/TmpFiles.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import java.io.File 4 | 5 | class TmpFiles(private val path: String) { 6 | fun delete( startsWidth: String = "" ) { 7 | File(path).listFiles()?.forEach { file -> 8 | if (file.isFile) { 9 | val deleteFile = startsWidth.isEmpty() ||file.name.startsWith(startsWidth) 10 | if (deleteFile) file.delete() 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/Trajectory.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import org.opencv.core.Mat 4 | import kotlin.math.cos 5 | import kotlin.math.sin 6 | 7 | 8 | class Trajectory(val x: List, val y: List, val a: List) { 9 | val size: Int 10 | get() = x.size 11 | 12 | fun getTransform(index: Int, T: Mat) { 13 | val a = this.a[index] 14 | 15 | T.put(0, 0, cos(a)) 16 | T.put(0, 1, -sin(a)) 17 | T.put(1, 0, sin(a)) 18 | T.put(1, 1, cos(a)) 19 | 20 | T.put(0, 2, x[index]) 21 | T.put(1, 2, y[index]) 22 | } 23 | } 24 | 25 | 26 | fun List.delta(to: List): List { 27 | val result = mutableListOf() 28 | for (index in this.indices) result.add(to[index] - this[index]) 29 | return result.toList() 30 | } 31 | 32 | 33 | fun List.distribute(): List { 34 | val result = mutableListOf() 35 | val first = this.first() 36 | val last = this.last() 37 | for (index in this.indices) result.add( first + (last - first) * index / (size - 1) ) 38 | return result.toList() 39 | } 40 | 41 | 42 | fun List.movingAverage(windowSize: Int): List { 43 | val result = mutableListOf() 44 | 45 | for(i in this.indices) { 46 | var sum = 0.0 47 | val from = if (i >= windowSize) (i - windowSize) else 0 48 | val to = if ((i + windowSize) < size) (i + windowSize) else (size - 1) 49 | val count = to - from + 1 50 | for(j in from..to) sum += this[j] 51 | result.add(sum / count) 52 | } 53 | 54 | return result.toList() 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dan/videostab/VideoFramesInput.kt: -------------------------------------------------------------------------------- 1 | package com.dan.videostab 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.documentfile.provider.DocumentFile 6 | import org.opencv.core.Mat 7 | import org.opencv.videoio.VideoCapture 8 | import org.opencv.videoio.Videoio.* 9 | import java.io.FileNotFoundException 10 | 11 | class VideoFramesInput( private val context: Context, private val _videoUri: Uri) : FramesInput() { 12 | companion object { 13 | private fun open(context: Context, uri: Uri): VideoCapture { 14 | val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw FileNotFoundException() 15 | val fd = pfd.detachFd() 16 | val videoCapture = VideoCapture(":$fd") 17 | pfd.close() 18 | if (!videoCapture.isOpened) throw FileNotFoundException() 19 | videoCapture.set(CAP_PROP_ORIENTATION_AUTO, 1.0 ) 20 | return videoCapture 21 | } 22 | } 23 | 24 | private var _fps: Int = 0 25 | private val _name: String 26 | private var _width: Int = 0 27 | private var _height: Int = 0 28 | private var _size: Int = 0 29 | 30 | override val fps: Int 31 | get() = _fps 32 | 33 | override val name: String 34 | get() = _name 35 | 36 | override val width: Int 37 | get() = _width 38 | 39 | override val height: Int 40 | get() = _height 41 | 42 | override val size: Int 43 | get() = _size 44 | 45 | override val imageUris: List? 46 | get() = null 47 | 48 | override val videoUri: Uri 49 | get() = _videoUri 50 | 51 | init { 52 | val document = DocumentFile.fromSingleUri(context, _videoUri) ?: throw FileNotFoundException() 53 | _name = fixName(document.name) 54 | withVideoInput { videoInput -> 55 | _fps = videoInput.get(CAP_PROP_FPS).toInt() 56 | _width = videoInput.get(CAP_PROP_FRAME_WIDTH).toInt() 57 | _height = videoInput.get(CAP_PROP_FRAME_HEIGHT).toInt() 58 | _size = videoInput.get(CAP_PROP_FRAME_COUNT).toInt() 59 | } 60 | 61 | if (_size <= 0) _size = VideoTools.countFrames(context, _videoUri) 62 | } 63 | 64 | override fun forEachFrame(callback: (Int, Int, Mat) -> Boolean) { 65 | var counter = 0 66 | withVideoInput { videoInput -> 67 | val frame = Mat() 68 | while(counter < _size && videoInput.read(frame)) { 69 | if (!callback(counter, _size, frame)) { 70 | break 71 | } 72 | counter++ 73 | } 74 | } 75 | } 76 | 77 | private fun withVideoInput(callback: (VideoCapture)->Unit) { 78 | val videoInput = open(context, _videoUri) 79 | callback(videoInput) 80 | videoInput.release() 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/layout/busy_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | 22 | 23 | 33 | 34 |