├── .github └── workflows │ ├── macos.yaml │ └── main.yaml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── cli.py ├── font.ttf ├── makefile ├── osd_gui.py ├── processor.py ├── requirements-noui.txt ├── requirements.txt ├── resources ├── ffmpeg.exe ├── font.ttf ├── user_ardu_24.png ├── user_ardu_36.png ├── user_bf_24.png └── user_bf_36.png ├── settings.py └── ws_osd_gen.spec /.github/workflows/macos.yaml: -------------------------------------------------------------------------------- 1 | name: Macos release 2 | 3 | on: 4 | workflow_dispatch: ~ 5 | 6 | jobs: 7 | build: 8 | runs-on: macos-12 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip3 install -U py2app 23 | 24 | - name: Package application 25 | run: | 26 | py2applet --make-setup osd_gui.py 27 | python3 setup.py py2app 28 | 29 | - name: Prepare files 30 | run: | 31 | mkdir release 32 | cp resources/* release 33 | cp dist/osd_gui.app release 34 | tar -a -c -f "release-${{ github.ref_name }}-macos-12.zip" release 35 | 36 | 37 | - uses: actions/upload-artifact@v3 38 | with: 39 | name: release-${{ github.ref_name }}-macos-12 40 | path: release-${{ github.ref_name }}-macos-12.zip -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Package and Release Python Application 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install pyinstaller 25 | 26 | - name: Package application 27 | run: | 28 | make release 29 | 30 | - name: Prepare files 31 | run: | 32 | mkdir release 33 | cp resources/* release 34 | cp dist/ws_osd_gen.exe release 35 | tar -a -c -f "release-${{ github.ref_name }}-windows.zip" release 36 | 37 | 38 | - name: Release 39 | uses: softprops/action-gh-release@v1 40 | with: 41 | name: Release ${{ github.ref_name }} 42 | prerelease: false 43 | draft: true 44 | fail_on_unmatched_files: true 45 | generate_release_notes: true 46 | files: release-${{ github.ref_name }}-windows.zip 47 | append_body: true 48 | body: | 49 | This is a latest release from master branch. 50 | --- 51 | ## Coffee needed 52 | If you like tool, you can buy me a coffee so keep working more overnights :) 53 | 54 | Buy Me A Coffee 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | *.pyc 4 | Makefile.venv 5 | .venv 6 | .vscode/launch.json 7 | ffmpeg.exe 8 | !resources/ffmpeg.exe 9 | .idea -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.flake8Enabled": true, 3 | "python.linting.enabled": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repository is no longer maintained in favor or https://github.com/avsaase/walksnail-osd-tool 2 | 3 | Thanks for all your support <3 4 | 5 | 6 | --- 7 | 8 | ![image](https://user-images.githubusercontent.com/1878027/210377476-0ca2a14e-71d7-40d8-add5-3d5d6f00a006.png) 9 | 10 | # Tool for generating OSD for Walksnail DVR 11 | 12 | That's easy, drag and drop files into UI, click Generate. 13 | Then import generated sequence into and video editing software and adjust framerate to 60fps if needed. 14 | 15 | # How to run 16 | 17 | ### Windows 18 | Nothing to do, Go to [Release page](https://github.com/kirek007/ws-osd-py/releases) 19 | 20 | ### Linux / MacOS 21 | 22 | Disclamer: I've tried my best to make it work on all systems, but it's only tested on Windows. So it might not work as expected 23 | on other systems. 24 | 25 | **Only works with Python 3.10 due to issues with wxPython and python-opencv libs** 26 | 27 | Install ffmpeg: 28 | 29 | For Linux: 30 | ```bash 31 | sudo apt install ffmpeg 32 | ``` 33 | 34 | For MacOs: 35 | ```bash 36 | brew install ffmpeg 37 | ``` 38 | 39 | Then clone respotiory and run app 40 | 41 | ```bash 42 | git clone https://github.com/kirek007/ws-osd-py.git 43 | cd ws-osd-py 44 | make run 45 | ``` 46 | 47 | ### CLI 48 | 49 | Thanks to @odgrace it's now possible to run tool without GUI (which is quite complicated sometimes). 50 | ```bash 51 | pip install -r requirements-noui.txt 52 | python3 cli.py -h #It will list all required parameters 53 | ``` 54 | 55 | 56 | ### Common issues for linux: 57 | If there is an issue with `ModuleNotFoundError: No module named 'attrdict'` try to install wxPython from wheel. 58 | 59 | Get packge link from here https://extras.wxpython.org/wxPython4/extras/linux/gtk3 60 | 61 | eg. for Ubuntu 20: `https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04/wxPython-4.2.0-cp310-cp310-linux_x86_64.whl` 62 | 63 | ```bash 64 | source .venv/bin/activate 65 | pip install -f wxPython 66 | ``` 67 | 68 | # Usage tutorial 69 | 70 | https://www.youtube.com/watch?v=we3F4rIXTqU 71 | 72 | # Fonts 73 | You can get fancy fonts from [Sneaky_FPV](https://sites.google.com/view/sneaky-fpv/home?pli=1), or get [default walksnails fonts](https://drive.google.com/file/d/1c3CRgXYQaM3Tt4ukLSIvoogScQZs9w49/view) 74 | 75 | # Examples 76 | Here are some results: 77 | 78 | https://www.youtube.com/watch?v=fHHXh9k-SGg 79 | 80 | https://www.youtube.com/watch?v=2u7wiJBIdCg 81 | 82 | # Ack 83 | Software is provided as is and it is open sourced so contributions are welcome! 84 | 85 | Feel free to create a ticket in case something is not working. 86 | 87 | 88 | ## Coffee needed 89 | If you like tool, you can buy me a coffee so keep working more overnights :) 90 | 91 | Buy Me A Coffee 92 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import secrets 5 | from argparse import ArgumentParser 6 | 7 | from processor import OsdGenConfig, OsdGenerator, Utils 8 | 9 | 10 | def implicit_path(video_path, ext): 11 | """ 12 | Attempts to find a path for the OSD and SRT files implicitly 13 | file_path: path to the video (mp4) file 14 | ext: file type suffix (e.g. osd or srt) 15 | """ 16 | folder, _ = os.path.splitext(video_path) 17 | implied_path = f"{folder}.{ext}" 18 | if not os.path.isfile(implied_path): 19 | raise FileNotFoundError(f'Tried to find {ext} file at ' 20 | f'"{implied_path}" based off {video_path}' 21 | f' but file not found') 22 | return implied_path 23 | 24 | 25 | def default_output_path(video_path): 26 | """ 27 | Determines a default path for the output PNGs and files 28 | """ 29 | random_hex = secrets.token_hex(3) 30 | _, video_file = os.path.split(video_path) 31 | file, ext = os.path.splitext(video_file) 32 | return f"{os.getcwd()}/{file}-{random_hex}" 33 | 34 | 35 | def video_osd_srt_parser(args): 36 | """ 37 | This function takes arguments and works out if the values provided are 38 | usable. For example, if three video paths are provided by only two OSD, 39 | This should raise an error 40 | """ 41 | if args.include_srt and args.srt_path is not None: 42 | srt_paths = args.srt_path 43 | 44 | else: 45 | srt_paths = [] 46 | logging.debug("Include SRT specified but no path given") 47 | for video_file in args.video_path: 48 | srt_paths.append(implicit_path(video_file, 'srt')) 49 | 50 | if args.osd_path is not None: 51 | osd_paths = args.osd_path 52 | else: 53 | osd_paths = [] 54 | logging.debug("No OSD Paths specified") 55 | for video_file in args.video_path: 56 | osd_paths.append(implicit_path(video_file, 'osd')) 57 | 58 | if args.include_srt: 59 | if not (len(args.video_path) == len(srt_paths) == len(osd_paths)): 60 | logging.debug(str(args.video_path)) 61 | logging.debug(str(srt_paths)) 62 | logging.debug(str(osd_paths)) 63 | msg = f"{len(args.video_path)} video paths, {len(srt_paths)} srt " \ 64 | f"paths and {len(osd_paths)} osd paths provided" 65 | raise ValueError(msg) 66 | else: 67 | if not (len(args.video_path) == len(osd_paths)): 68 | logging.debug(str(args.video_path)) 69 | logging.debug(str(osd_paths)) 70 | msg = f"{len(args.video_path)} video paths and {len(osd_paths)} " \ 71 | f"osd paths provided" 72 | raise ValueError(msg) 73 | return args.video_path, osd_paths, srt_paths 74 | 75 | 76 | if __name__ == '__main__': 77 | 78 | parser = ArgumentParser( 79 | description="CLI tool for OSD generator", 80 | epilog='Example: python .\cli.py --video-path "video.mp4" --font-path "sneaky_font.png"', 81 | ) 82 | parser.add_argument('--video-path', help='Path to the video file', 83 | required=True, nargs='+') 84 | parser.add_argument('--osd-path', help='Path to the OSD file. If none ' 85 | 'specified, it will look in the same' 86 | ' directory as the video path', 87 | nargs='?') 88 | parser.add_argument('--srt-path', help='Path to SRT file. If none ' 89 | 'specified, it will look in the same' 90 | ' directory as the video path', 91 | nargs='?') 92 | parser.add_argument('--font-path', required=True, 93 | help='Path to font file - e.g (INAV_36.png)') 94 | parser.add_argument('--output-file', 95 | help='Output path for PNG folder and finished video') 96 | parser.add_argument('--remove-png', default=False, action='store_true', 97 | help='Delete PNGs after rendering video. Option only ' 98 | 'works if rendering video') 99 | parser.add_argument('--no-video', default=False, action='store_true', 100 | help='Do not render video, only create the PNGs. ' 101 | 'Default Behavior is to render video') 102 | parser.add_argument('--offset-top', type=int, default=0, 103 | help='Offset from top for OSD') 104 | parser.add_argument('--offset-left', help='Offset from left for OSD', 105 | default=0, type=int) 106 | parser.add_argument('--osd-zoom', type=int, default=100, 107 | help='Scaling of OSD elements') 108 | parser.add_argument('--render-upscale', default=False, 109 | help='Increase video dimensions to 1440p') 110 | parser.add_argument('--include-srt', action='store_true', default=False, 111 | help='Include SRT data from VTX') 112 | parser.add_argument('--hide-sensitive-osd', action='store_true', 113 | help='Hide Sensitive Elements like lat, lon, altitude, ' 114 | 'home', default=False) 115 | parser.add_argument('--no-hw-accel', action='store_true', default=False, 116 | help='Disable Hardware Acceleration of ffmpeg if flag ' 117 | 'specified. By default and without this flag, ' 118 | 'Hardware acceleration will be used') 119 | parser.add_argument('--fast-srt', action='store_true', default=False, 120 | help='') 121 | parser.add_argument('--no-concat', action='store_true', default=False, 122 | help='If multiple files are provided, by default they ' 123 | 'will be concatenated at the end. using this flag' 124 | ' will prevent concatenation') 125 | 126 | args = parser.parse_args() 127 | 128 | video, osd, srt = video_osd_srt_parser(args) 129 | 130 | png_folders = [default_output_path(x) for x in video] 131 | video_outputs = [f"{x}_osd.mp4" for x in png_folders] 132 | 133 | if not args.no_concat and not args.output_file and len(video) > 1: 134 | # if we are concatenating, we will need an output_path 135 | raise ValueError('Multiple videos provided. Please provide ' 136 | '--output-path') 137 | 138 | for video, osd, srt, png_folder in zip(video, osd, srt, png_folders): 139 | generator_config = OsdGenConfig( 140 | video_path=video, 141 | osd_path=osd, 142 | srt_path=srt, 143 | font_path=args.font_path, 144 | output_path=png_folder, 145 | offset_top=args.offset_top, 146 | offset_left=args.offset_left, 147 | osd_zoom=args.osd_zoom, 148 | render_upscale=args.render_upscale, 149 | include_srt=args.include_srt, 150 | hide_sensitive_osd=args.hide_sensitive_osd, 151 | use_hw=not args.no_hw_accel, 152 | fast_srt=args.fast_srt 153 | ) 154 | 155 | gen = OsdGenerator(generator_config) 156 | gen.main() 157 | if not args.no_video: 158 | try: 159 | gen.render() 160 | finally: 161 | # will always clean-up PNGs, if flag is specified 162 | if args.remove_png: 163 | shutil.rmtree(png_folder) 164 | 165 | if not args.no_concat and len(video_outputs) > 1: 166 | Utils.concatenate_output_files( 167 | video_outputs, 168 | args.output_file 169 | ) 170 | for file in video_outputs: 171 | os.remove(file) 172 | -------------------------------------------------------------------------------- /font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/font.ttf -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | include Makefile.venv 2 | Makefile.venv: 3 | curl \ 4 | -o Makefile.fetched \ 5 | -L "https://github.com/sio/Makefile.venv/raw/v2022.07.20/Makefile.venv" 6 | echo "147b164f0cbbbe4a2740dcca6c9adb6e9d8d15b895be3998697aa6a821a277d8 *Makefile.fetched" \ 7 | && mv Makefile.fetched Makefile.venv 8 | 9 | .PHONY: release 10 | release: venv 11 | $(VENV)/pyinstaller --onefile .\osd_gui.py -n ws_osd_gen 12 | 13 | .PHONY: run 14 | run: venv show-venv 15 | $(VENV)/python -c 'import sys; valid=(sys.version_info > (3,9) and sys.version_info < (3,11)); sys.exit(0) if valid else sys.exit(1)' || (echo "Python 3.10 is required"; exit 1) 16 | $(VENV)/python osd_gui.py 17 | 18 | -------------------------------------------------------------------------------- /osd_gui.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import logging 3 | import wx 4 | from processor import OSDFile, OsdFont, OsdPreview, VideoFile 5 | import wx.lib.agw.hyperlink as hl 6 | 7 | from settings import appState 8 | from pubsub import pub 9 | 10 | 11 | class PubSubEvents(str, Enum): 12 | FileSelected = "FileDrop" 13 | ConfigUpdate = "ConfigUpdate" 14 | ApplicationConfigured = "ApplicationConfigured" 15 | PreviewUpdate = "PreviewUpdate" 16 | 17 | 18 | class FilesDropTarget(wx.FileDropTarget): 19 | """""" 20 | 21 | # ---------------------------------------------------------------------- 22 | def __init__(self, window): 23 | """Constructor""" 24 | wx.FileDropTarget.__init__(self) 25 | self.window = window 26 | 27 | # ---------------------------------------------------------------------- 28 | def OnDropFiles(self, x, y, filenames): 29 | filename = filenames[0] 30 | logging.debug("File drop: %s", filename) 31 | appState.getOptionsByPath(filename) 32 | pub.sendMessage(PubSubEvents.ConfigUpdate) 33 | 34 | return True 35 | 36 | 37 | class FileInputPanel(wx.Panel): 38 | 39 | def __init__(self, parent): 40 | wx.Panel.__init__(self, parent=parent) 41 | 42 | file_drop_target = FilesDropTarget(self) 43 | self.SetDropTarget(file_drop_target) 44 | 45 | lbl_info = wx.StaticText( 46 | self, label="Drag and drop all files here", style=wx.ALIGN_CENTER) 47 | lbl_video = wx.StaticText(self, label="Selected video path:") 48 | self.lbl_video_sel = wx.StaticText(self, label="") 49 | self.lbl_video_info = wx.StaticText(self, label="") 50 | lbl_osd = wx.StaticText(self, label="Selected osd file path") 51 | self.lbl_osd_sel = wx.StaticText(self, label="") 52 | self.lbl_osd_info = wx.StaticText(self, label="") 53 | lbl_font = wx.StaticText(self, label="Selected font path") 54 | self.lbl_font_sel = wx.StaticText(self, label="") 55 | self.lbl_font_info = wx.StaticText(self, label="") 56 | lbl_output = wx.StaticText(self, label="Output directory") 57 | self.lbl_output_sel = wx.StaticText(self, label="") 58 | self.lbl_output_info = wx.StaticText(self, label="") 59 | 60 | self.font_default = wx.Font(18, wx.DEFAULT, wx.NORMAL, wx.BOLD) 61 | self.font_bold = wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.BOLD) 62 | self.font_warning = wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.BOLD) 63 | self.lbl_output_info.SetForegroundColour((255, 0, 0)) 64 | 65 | lbl_info.SetFont(self.font_default) 66 | # self.lbl_osd_sel.SetFont(self.font_bold) 67 | # self.lbl_osd_info.SetFont(self.font_bold) 68 | # self.lbl_video_sel.SetFont(self.font_bold) 69 | # self.lbl_video_info.SetFont(self.font_bold) 70 | # self.lbl_font_sel.SetFont(self.font_bold) 71 | # self.lbl_font_info.SetFont(self.font_bold) 72 | # self.lbl_output_sel.SetFont(self.font_bold) 73 | # self.lbl_output_info.SetFont(self.font_bold) 74 | 75 | box = wx.StaticBox(self, -1, "Import files") 76 | bsizer = wx.StaticBoxSizer(box, wx.VERTICAL) 77 | hsizer = wx.BoxSizer() 78 | 79 | bsizer.Add(lbl_info, 0, wx.ALL, 5) 80 | bsizer.Add(lbl_video, 0, wx.ALL, 5) 81 | bsizer.Add(self.lbl_video_sel, 0, wx.ALL, 5) 82 | bsizer.Add(self.lbl_video_info, 0, wx.ALL, 5) 83 | bsizer.Add(lbl_osd, 0, wx.ALL, 5) 84 | bsizer.Add(self.lbl_osd_sel, 0, wx.ALL, 5) 85 | bsizer.Add(self.lbl_osd_info, 0, wx.ALL, 5) 86 | bsizer.Add(lbl_font, 0, wx.ALL, 5) 87 | bsizer.Add(self.lbl_font_sel, 0, wx.ALL, 5) 88 | bsizer.Add(self.lbl_font_info, 0, wx.ALL, 5) 89 | bsizer.Add(lbl_output, 0, wx.ALL, 5) 90 | bsizer.Add(self.lbl_output_sel, 0, wx.ALL, 5) 91 | bsizer.Add(self.lbl_output_info, 0, wx.ALL, 5) 92 | 93 | bsizer.Add(hsizer, 0, wx.LEFT) 94 | main_sizer = wx.BoxSizer() 95 | main_sizer.Add(bsizer, 1, wx.EXPAND | wx.ALL, 10) 96 | self.SetSizer(main_sizer) 97 | 98 | pub.subscribe(self.eventConfigUpdate, PubSubEvents.ConfigUpdate) 99 | 100 | pass 101 | 102 | def eventConfigUpdate(self): 103 | self.updateSettings() 104 | 105 | def updateSettings(self): 106 | """ 107 | Write text to the text control 108 | """ 109 | self.lbl_video_sel.SetLabel(appState._video_path) 110 | self.lbl_osd_sel.SetLabel(appState._osd_path) 111 | self.lbl_font_sel.SetLabel(appState._font_path) 112 | self.lbl_output_sel.SetLabel(appState._output_path) 113 | 114 | self.updateInfo() 115 | 116 | def updateInfo(self): 117 | if appState._osd_path: 118 | soft_name = OSDFile(appState._osd_path, None).get_software_name() 119 | self.lbl_osd_info.SetLabel("Recognized '%s' software." % soft_name) 120 | 121 | if appState._font_path: 122 | font = OsdFont(appState._font_path) 123 | font_size_text = ("HD" if font.is_hd() else "SD") 124 | self.lbl_font_info.SetLabel( 125 | "Recognized '%s' font." % font_size_text) 126 | 127 | if appState._video_path: 128 | video = VideoFile(appState._video_path) 129 | video_size_text = ("HD" if video.is_hd() else "SD") 130 | self.lbl_video_info.SetLabel( 131 | "Recognized '%s' video." % video_size_text) 132 | 133 | if appState.is_output_exists(): 134 | self.lbl_output_info.SetLabel( 135 | "Output directory already exists, remove it") 136 | else: 137 | self.lbl_output_info.SetLabel("") 138 | 139 | if appState._font_path and appState._video_path: 140 | font = OsdFont(appState._font_path) 141 | video = VideoFile(appState._video_path) 142 | if video.is_hd() != font.is_hd(): 143 | self.lbl_font_info.SetLabel( 144 | "Font doesn't match video resolution, please select '%s' font " % video_size_text) 145 | 146 | 147 | class ButtonsPanel(wx.Panel): 148 | 149 | def __init__(self, parent): 150 | wx.Panel.__init__(self, parent=parent) 151 | 152 | box = wx.StaticBox(self, -1, "") 153 | bsizer = wx.StaticBoxSizer(box, wx.VERTICAL) 154 | bsizer.AddSpacer(20) 155 | hsizer = wx.BoxSizer() 156 | hsizer.AddSpacer(20) 157 | 158 | vsizer = wx.BoxSizer(wx.VERTICAL) 159 | self.btnStartPng = wx.Button(self, label="Generate PNG sequence only") 160 | self.btnStartPng.Disable() 161 | vsizer.Add(self.btnStartPng) 162 | hsizer.Add(vsizer) 163 | hsizer.AddSpacer(20) 164 | 165 | vsizer = wx.BoxSizer(wx.VERTICAL) 166 | self.btnStartVideo = wx.Button(self, label="Render video with OSD") 167 | self.btnStartVideo.Disable() 168 | vsizer.Add(self.btnStartVideo) 169 | hsizer.Add(vsizer) 170 | hsizer.AddSpacer(20) 171 | 172 | vsizer = wx.BoxSizer(wx.VERTICAL) 173 | self.cbo_upscale = wx.CheckBox(self, label="Upscale video to 1440p") 174 | vsizer.Add(self.cbo_upscale) 175 | hsizer.Add(vsizer) 176 | bsizer.Add(hsizer, 0, wx.LEFT) 177 | 178 | main_sizer = wx.BoxSizer() 179 | main_sizer.Add(bsizer, 1, wx.EXPAND | wx.ALL, 10) 180 | bsizer.AddSpacer(20) 181 | self.SetSizer(main_sizer) 182 | 183 | pub.subscribe(self.eventConfigUpdate, PubSubEvents.ConfigUpdate) 184 | 185 | self.btnStartPng.Bind(wx.EVT_BUTTON, self.btnStartPngClick) 186 | self.btnStartVideo.Bind(wx.EVT_BUTTON, self.btnStartVideoClick) 187 | self.cbo_upscale.Bind(wx.EVT_CHECKBOX, self.chekboxClick) 188 | 189 | def chekboxClick(self, event): 190 | appState.render_upscale = bool(self.cbo_upscale.Value) 191 | 192 | def eventConfigUpdate(self): 193 | configured = appState.is_configured() 194 | self.btnStartPng.Enable(configured) 195 | self.btnStartVideo.Enable(configured) 196 | 197 | def btnStartVideoClick(self, event): 198 | done = self._render_png() 199 | if done: 200 | self._render_video() 201 | mes = wx.MessageBox("Render done.", "OK") 202 | 203 | def btnStartPngClick(self, event): 204 | if self._render_png(): 205 | mes = wx.MessageBox( 206 | "OSD overlay files are in '%s' directory" % appState._output_path, "OK") 207 | 208 | def _render_video(self): 209 | status = appState.osd_init() 210 | pd = wx.ProgressDialog( 211 | "Rendering video", "Check console log for status", 1, self, style=wx.PD_APP_MODAL) 212 | pd.Show() 213 | appState.osd_render_video() 214 | _osd_gen = appState._osd_gen 215 | while not _osd_gen.render_done: 216 | wx.MilliSleep(200) 217 | pd.Update(0) 218 | pd.Update(1) 219 | pd.Destroy() 220 | pub.sendMessage(PubSubEvents.ConfigUpdate) 221 | 222 | def _render_png(self): 223 | canceled = False 224 | status = appState.osd_init() 225 | pd = wx.ProgressDialog("Generating OSD", "Processing frames...", status.total_frames + 1, self, 226 | style=wx.PD_CAN_ABORT | wx.PD_APP_MODAL | wx.PD_REMAINING_TIME | wx.PD_ELAPSED_TIME | wx.PD_SMOOTH) 227 | pd.Show() 228 | appState.osd_start_process() 229 | keepGoing = True 230 | while keepGoing and not status.is_complete(): 231 | wx.MilliSleep(200) 232 | keepGoing, skip = pd.Update(status.current_frame) 233 | if not keepGoing: 234 | canceled = True 235 | appState.osd_cancel_process() 236 | 237 | pd.Update(status.total_frames) 238 | pd.Destroy() 239 | pub.sendMessage(PubSubEvents.ConfigUpdate) 240 | 241 | return not canceled 242 | 243 | 244 | class OsdSettingsPanel(wx.Panel): 245 | 246 | def __init__(self, parent): 247 | wx.Panel.__init__(self, parent=parent) 248 | 249 | box = wx.StaticBox(self, -1, "OSD position") 250 | bsizer = wx.StaticBoxSizer(box, wx.VERTICAL) 251 | bsizer.AddSpacer(20) 252 | hsizer = wx.BoxSizer() 253 | hsizer.AddSpacer(20) 254 | vsizer = wx.BoxSizer(wx.VERTICAL) 255 | lbl = wx.StaticText(self, label='Offset left') 256 | vsizer.Add(lbl) 257 | self.osdOffsetLeft = wx.Slider(self, name="OSD offset X", minValue=-200, maxValue=600, 258 | value=0, style=wx.SL_HORIZONTAL | wx.SL_VALUE_LABEL, size=wx.Size(150, -1)) 259 | self.osdOffsetLeft.Bind(wx.EVT_SCROLL, self.eventSliderUpdated) 260 | vsizer.Add(self.osdOffsetLeft) 261 | hsizer.Add(vsizer) 262 | hsizer.AddSpacer(20) 263 | vsizer = wx.BoxSizer(wx.VERTICAL) 264 | lbl = wx.StaticText(self, label='Offset top') 265 | vsizer.Add(lbl) 266 | self.osdOffsetTop = wx.Slider(self, name="OSD offset Y", minValue=-200, maxValue=600, 267 | value=0, style=wx.SL_HORIZONTAL | wx.SL_VALUE_LABEL, size=wx.Size(150, -1)) 268 | self.osdOffsetTop.Bind(wx.EVT_SCROLL, self.eventSliderUpdated) 269 | vsizer.Add(self.osdOffsetTop) 270 | hsizer.Add(vsizer) 271 | hsizer.AddSpacer(20) 272 | vsizer = wx.BoxSizer(wx.VERTICAL) 273 | lbl = wx.StaticText(self, label='Zoom') 274 | vsizer.Add(lbl) 275 | self.osdZoom = wx.Slider(self, name="OSD zoom", minValue=80, maxValue=200, 276 | value=100, style=wx.SL_HORIZONTAL | wx.SL_VALUE_LABEL, size=wx.Size(150, -1)) 277 | self.osdZoom.Bind(wx.EVT_SCROLL, self.eventSliderUpdated) 278 | vsizer.Add(self.osdZoom) 279 | hsizer.Add(vsizer) 280 | vsizer = wx.BoxSizer(wx.VERTICAL) 281 | btnReset = wx.Button(self, label='Reset') 282 | self.cbo_srt = wx.CheckBox(self, label="Include SRT data if loaded") 283 | self.cbo_srt_fast = wx.CheckBox(self, label="Fast SRT render (less pretty font") 284 | 285 | self.cbo_hide_data = wx.CheckBox( 286 | self, label="Hide sensitive OSD values (GPS, Alt, Home dist)") 287 | self.cbo_use_hw = wx.CheckBox( 288 | self, label="Use hardware acceleration for video enconding (experimental)") 289 | btnReset.Bind(wx.EVT_BUTTON, self.btnResetClick) 290 | 291 | bsizer.Add(hsizer, 0, wx.LEFT) 292 | bsizer.Add(btnReset, 0, wx.CENTER) 293 | bsizer.AddSpacer(10) 294 | bsizer.Add(self.cbo_srt, 0, wx.CENTER) 295 | bsizer.Add(self.cbo_srt_fast, 0, wx.CENTER) 296 | bsizer.AddSpacer(10) 297 | bsizer.Add(self.cbo_hide_data, 0, wx.CENTER) 298 | bsizer.AddSpacer(10) 299 | bsizer.Add(self.cbo_use_hw, 0, wx.CENTER) 300 | main_sizer = wx.BoxSizer() 301 | main_sizer.Add(bsizer, 1, wx.EXPAND | wx.ALL, 10) 302 | bsizer.AddSpacer(10) 303 | self.SetSizer(main_sizer) 304 | 305 | self.cbo_srt_fast.Value = True 306 | self.cbo_srt.Value = True 307 | self.cbo_use_hw.Value = True 308 | 309 | self.cbo_srt.Bind(wx.EVT_CHECKBOX, self.chekboxClick) 310 | self.cbo_hide_data.Bind(wx.EVT_CHECKBOX, self.chekboxClick) 311 | self.cbo_use_hw.Bind(wx.EVT_CHECKBOX, self.chekboxClick) 312 | self.cbo_srt_fast.Bind(wx.EVT_CHECKBOX, self.chekboxClick) 313 | 314 | self.chekboxClick(None) 315 | 316 | def chekboxClick(self, event): 317 | appState._include_srt = bool(self.cbo_srt.Value) 318 | appState._hide_sensitive_osd = bool(self.cbo_hide_data.Value) 319 | appState._use_hw = bool(self.cbo_use_hw.Value) 320 | appState._fast_srt = bool(self.cbo_srt_fast.Value) 321 | pub.sendMessage(PubSubEvents.ConfigUpdate) 322 | 323 | def btnResetClick(self, event): 324 | self.osdOffsetLeft.SetValue(0) 325 | self.osdOffsetTop.SetValue(0) 326 | self.osdZoom.SetValue(100) 327 | appState.updateOsdPosition( 328 | self.osdOffsetLeft.Value, self.osdOffsetTop.Value, self.osdZoom.Value) 329 | 330 | pub.sendMessage(PubSubEvents.PreviewUpdate) 331 | 332 | def eventSliderUpdated(self, event): 333 | logging.debug(f"Slider updated.") 334 | appState.updateOsdPosition( 335 | self.osdOffsetLeft.Value, self.osdOffsetTop.Value, self.osdZoom.Value) 336 | 337 | pub.sendMessage(PubSubEvents.PreviewUpdate) 338 | 339 | 340 | class BottomPanel(wx.Panel): 341 | 342 | def __init__(self, parent): 343 | wx.Panel.__init__(self, parent=parent) 344 | 345 | box = wx.StaticBox(self, -1, "") 346 | bsizer = wx.StaticBoxSizer(box, wx.VERTICAL) 347 | bsizer.AddSpacer(20) 348 | hsizer = wx.BoxSizer() 349 | hsizer.AddSpacer(20) 350 | 351 | vsizer = wx.BoxSizer(wx.VERTICAL) 352 | hyper2 = hl.HyperLinkCtrl(self, -1, "Latest version always here!", 353 | URL="https://github.com/kirek007/ws-osd-py") 354 | vsizer.Add(hyper2) 355 | hsizer.Add(vsizer) 356 | hsizer.AddSpacer(20) 357 | vsizer = wx.BoxSizer(wx.VERTICAL) 358 | hyper2 = hl.HyperLinkCtrl(self, -1, "Psst, this is coffee driven application ;)", 359 | URL="https://www.buymeacoffee.com/kirek") 360 | vsizer.Add(hyper2) 361 | hsizer.Add(vsizer) 362 | bsizer.Add(hsizer, 0, wx.LEFT) 363 | main_sizer = wx.BoxSizer() 364 | main_sizer.Add(bsizer, 1, wx.EXPAND | wx.ALL, 10) 365 | bsizer.AddSpacer(20) 366 | self.SetSizer(main_sizer) 367 | 368 | 369 | class PrewievPanel(wx.Panel): 370 | 371 | def __init__(self, parent): 372 | wx.Panel.__init__(self, parent=parent) 373 | 374 | box = wx.StaticBox(self, -1, "OSD Preview") 375 | bsizer = wx.StaticBoxSizer(box, wx.VERTICAL) 376 | self.imageCtrl = wx.StaticBitmap(self, wx.ID_ANY, 377 | wx.Bitmap(wx.Image(640, 360, True))) 378 | bsizer.Add(self.imageCtrl, 20, wx.EXPAND | wx.ALL, 20) 379 | main_sizer = wx.BoxSizer() 380 | main_sizer.Add(bsizer, 0, wx.EXPAND | wx.ALL, 20) 381 | self.SetSizer(main_sizer) 382 | 383 | pub.subscribe(self.eventConfigUpdate, PubSubEvents.ConfigUpdate) 384 | pub.subscribe(self.eventConfigUpdate, PubSubEvents.PreviewUpdate) 385 | 386 | def eventConfigUpdate(self): 387 | if not appState.is_configured(): 388 | return 389 | logging.debug("Preview update requested.") 390 | self.onView() 391 | 392 | def onView(self): 393 | prev = OsdPreview(appState.get_osd_config()) 394 | image = prev.generate_preview( 395 | (appState.offsetLeft, appState.offsetTop), appState.osdZoom) 396 | self.imageCtrl.SetBitmap(wx.Bitmap.FromBuffer(640, 360, image)) 397 | self.imageCtrl.Refresh() 398 | self.Refresh() 399 | 400 | 401 | class MainWindow(wx.Frame): 402 | 403 | def __init__(self): 404 | wx.Frame.__init__(self, parent=None, 405 | title="Walksnail OSD overlay tool") 406 | main_sizer = wx.BoxSizer(wx.HORIZONTAL) 407 | 408 | vsizer = wx.BoxSizer(wx.VERTICAL) 409 | fileInput = FileInputPanel(self) 410 | osdSettings = OsdSettingsPanel(self) 411 | buttonsPanel = ButtonsPanel(self) 412 | bottomPanel = BottomPanel(self) 413 | prewievPanel = PrewievPanel(self) 414 | vsizer.Add(fileInput, 0, wx.EXPAND | wx.ALL, 0) 415 | vsizer.Add(osdSettings, 0, wx.EXPAND | wx.ALL, 0) 416 | vsizer.Add(buttonsPanel, 0, wx.EXPAND | wx.ALL, 0) 417 | vsizer.Add(bottomPanel, 0, wx.EXPAND | wx.ALL, 0) 418 | main_sizer.Add(vsizer) 419 | vsizer = wx.BoxSizer(wx.VERTICAL) 420 | vsizer.Add(prewievPanel, 0, wx.EXPAND, 0) 421 | main_sizer.Add(vsizer) 422 | self.SetSizer(main_sizer) 423 | self.Show() 424 | 425 | 426 | if __name__ == "__main__": 427 | logging.basicConfig(level=logging.DEBUG) 428 | app = wx.App(False) 429 | 430 | frame = MainWindow() 431 | frame.Size = (1300, 790) 432 | frame.MinSize = wx.Size(1300, 790) 433 | app.MainLoop() 434 | -------------------------------------------------------------------------------- /processor.py: -------------------------------------------------------------------------------- 1 | import cProfile 2 | from concurrent.futures import ThreadPoolExecutor 3 | from dataclasses import dataclass, field 4 | import io 5 | import logging 6 | import multiprocessing 7 | import os 8 | from datetime import datetime 9 | import platform 10 | from pstats import SortKey 11 | import pstats 12 | import queue 13 | from struct import unpack 14 | import subprocess 15 | from threading import Thread 16 | import cv2 17 | import numpy as np 18 | import ffmpeg 19 | import srt 20 | from PIL import ImageFont, ImageDraw, Image 21 | 22 | 23 | class CountsPerSec: 24 | """ 25 | Class that tracks the number of occurrences ("counts") of an 26 | arbitrary event and returns the frequency in occurrences 27 | (counts) per second. The caller must increment the count. 28 | """ 29 | 30 | def __init__(self): 31 | self._start_time = None 32 | self._num_occurrences = 0 33 | 34 | def start(self): 35 | self._start_time = datetime.now() 36 | return self 37 | 38 | def increment(self): 39 | self._num_occurrences += 1 40 | 41 | def countsPerSec(self): 42 | elapsed_time = (datetime.now() - self._start_time).total_seconds() 43 | return self._num_occurrences / elapsed_time 44 | 45 | 46 | class OsdFont: 47 | 48 | GLYPH_HD_H = 18 * 3 49 | GLYPH_HD_W = 12 * 3 50 | GLYPH_SD_H = 18 * 2 51 | GLYPH_SD_W = 12 * 2 52 | 53 | def __init__(self, path): 54 | self.font = cv2.imread(path, cv2.IMREAD_UNCHANGED) 55 | 56 | def get_glyph(self, index): 57 | size_h = self.GLYPH_HD_H if self.is_hd() else self.GLYPH_SD_H 58 | size_w = self.GLYPH_HD_W if self.is_hd() else self.GLYPH_SD_W 59 | 60 | pos_y = size_h * (index) 61 | pos_y2 = pos_y + size_h 62 | glyph = self.font[pos_y:pos_y2, 0:size_w] 63 | 64 | if glyph.size > 0: 65 | return glyph 66 | else: 67 | return None 68 | 69 | def is_hd(self): 70 | font_w = self.font.shape[1] 71 | return font_w == self.GLYPH_HD_W 72 | 73 | def get_srt_font_size(self): 74 | if self.is_hd(): 75 | return 32 76 | else: 77 | return 24 78 | 79 | 80 | class OSDFile: 81 | 82 | READ_SIZE = 2124 83 | 84 | def __init__(self, path, font: OsdFont): 85 | self.osdFile = open(path, "rb") 86 | self.fcType = self.osdFile.read(4).decode("utf-8") 87 | self.magic = self.osdFile.read(36) 88 | self.font = font 89 | 90 | def peek_frame(self, frame_no): 91 | frame_start = frame_no * self.READ_SIZE 92 | current_pos = self.osdFile.tell() 93 | self.osdFile.seek(frame_start) 94 | frame = self.read_frame() 95 | self.osdFile.seek(current_pos) 96 | 97 | return frame 98 | 99 | def read_frame(self): 100 | 101 | rawData = self.osdFile.read(self.READ_SIZE) 102 | if len(rawData) < self.READ_SIZE: 103 | return False 104 | 105 | return Frame(rawData, self.font) 106 | 107 | def get_software_name(self): 108 | mapping = { 109 | 'BTFL': 'Betaflight', 110 | 'ARDU': 'Ardupilot', 111 | 'INAV': 'INav', 112 | } 113 | return mapping.get(self.fcType, 'Unknown') 114 | 115 | 116 | @dataclass 117 | class MaskObject: 118 | name: str 119 | index: int 120 | length: int 121 | pos: int = -1 122 | 123 | def __eq__(self, other: int): 124 | return self.index == other 125 | 126 | 127 | class Frame: 128 | frame_w = 53 129 | frame_h = 20 130 | 131 | def __init__(self, data, font: OsdFont): 132 | raw_time = data[0:4] 133 | self.startTime = unpack(" 0: 172 | mask_from = item.pos + 1 173 | mask_to = item.pos + item.length 174 | else: 175 | mask_from = item.pos + item.length 176 | mask_to = item.pos - 1 177 | 178 | for mask_pos in range(mask_from, mask_to): 179 | glyphs_arr[mask_pos] = self.mask_glyph 180 | 181 | # logging.debug("Frame data: %s" % glyph_no) 182 | 183 | return glyphs_arr 184 | 185 | def get_osd_frame_glyphs(self, hide): 186 | glyphs = self.__convert_to_glyphs(hide) 187 | osd_frame = [] 188 | 189 | gi = 0 190 | for y in range(self.frame_h): 191 | frame_line = [] 192 | for x in range(self.frame_w): 193 | frame_line.append(glyphs[gi]) 194 | gi += 1 195 | osd_frame.append(frame_line) 196 | return osd_frame 197 | 198 | 199 | class VideoFrame: 200 | 201 | def __init__(self, data): 202 | self.data = data 203 | 204 | 205 | class VideoFile: 206 | 207 | def __init__(self, path): 208 | self.videoFile = cv2.VideoCapture(path) 209 | 210 | def get_current_time(self): 211 | return self.videoFile.get(cv2.CAP_PROP_POS_MSEC) 212 | 213 | def is_hd(self): 214 | h, w = self.get_size() 215 | return h == 1080 216 | 217 | def get_size(self): 218 | width = self.videoFile.get(cv2.CAP_PROP_FRAME_WIDTH) # float `width` 219 | height = self.videoFile.get( 220 | cv2.CAP_PROP_FRAME_HEIGHT) # float `height` 221 | return int(height), int(width) 222 | 223 | def get_total_frames(self): 224 | return int(self.videoFile.get(cv2.CAP_PROP_FRAME_COUNT)) 225 | 226 | def get_fps(self): 227 | fps = self.videoFile.get(cv2.CAP_PROP_FPS) 228 | return fps 229 | 230 | def read_frame(self): 231 | ret, frame = self.videoFile.read() 232 | if not ret: 233 | return None 234 | 235 | if len(frame) == 0: 236 | return None 237 | 238 | return VideoFrame(frame) 239 | 240 | 241 | class OsdGenConfig: 242 | def __init__(self, video_path, osd_path, font_path, srt_path, output_path, offset_left, offset_top, osd_zoom, render_upscale, include_srt, hide_sensitive_osd, use_hw, fast_srt) -> None: 243 | self.video_path = video_path 244 | self.osd_path = osd_path 245 | self.font_path = font_path 246 | self.srt_path = srt_path 247 | self.output_path = output_path 248 | self.offset_left = offset_left 249 | self.offset_top = offset_top 250 | self.osd_zoom = osd_zoom 251 | self.render_upscale = render_upscale 252 | self.include_srt = include_srt 253 | self.hide_sensitive_osd = hide_sensitive_osd 254 | self.use_hw = use_hw 255 | self.fast_srt = fast_srt 256 | 257 | 258 | class OsdGenStatus: 259 | def __init__(self) -> None: 260 | self.current_frame = -1 261 | self.total_frames = -1 262 | self.fps = -1 263 | 264 | def update(self, current, total, fps) -> None: 265 | self.current_frame = current 266 | self.total_frames = total 267 | self.fps = fps 268 | 269 | def is_complete(self) -> bool: 270 | return self.current_frame >= self.total_frames 271 | 272 | 273 | class Utils: 274 | 275 | @staticmethod 276 | def merge_images(img, overlay, x, y, zoom): 277 | scale_percent = zoom # percent of original size 278 | width = int(overlay.shape[1] * scale_percent / 100) 279 | height = int(overlay.shape[0] * scale_percent / 100) 280 | dim = (width, height) 281 | img_overlay_res = cv2.resize( 282 | overlay, dim, interpolation=cv2.INTER_CUBIC) 283 | # img_crop = img_overlay_res[y:img.shape[0],x:img.shape[1]] 284 | 285 | # Image ranges 286 | y1, y2 = max(0, y), min(img.shape[0], y + img_overlay_res.shape[0]) 287 | x1, x2 = max(0, x), min(img.shape[1], x + img_overlay_res.shape[1]) 288 | # Overlay ranges 289 | y1o, y2o = max(0, -y), min(img_overlay_res.shape[0], img.shape[0] - y) 290 | x1o, x2o = max(0, -x), min(img_overlay_res.shape[1], img.shape[1] - x) 291 | 292 | img_crop = img[y1:y2, x1:x2] 293 | img_overlay_crop = img_overlay_res[y1o:y2o, x1o:x2o] 294 | 295 | img_crop[:] = img_overlay_crop + img_crop 296 | img = img_crop 297 | # img[y:y+img_crop.shape[0], x:x+img_crop.shape[1]] = img_crop 298 | 299 | @staticmethod 300 | def overlay_image_alpha(img, img_overlay, x, y, zoom): 301 | scale_percent = zoom # percent of original size 302 | width = int(img_overlay.shape[1] * scale_percent / 100) 303 | height = int(img_overlay.shape[0] * scale_percent / 100) 304 | dim = (width, height) 305 | img_overlay_res = cv2.resize( 306 | img_overlay, dim, interpolation=cv2.INTER_CUBIC) 307 | 308 | # Mask 309 | alpha_mask = img_overlay_res[:, :, 3] / 255.0 310 | img_overlay_res = img_overlay_res[:, :, :3] 311 | 312 | # Image ranges 313 | y1, y2 = max(0, y), min(img.shape[0], y + img_overlay_res.shape[0]) 314 | x1, x2 = max(0, x), min(img.shape[1], x + img_overlay_res.shape[1]) 315 | 316 | # Overlay ranges 317 | y1o, y2o = max(0, -y), min(img_overlay_res.shape[0], img.shape[0] - y) 318 | x1o, x2o = max(0, -x), min(img_overlay_res.shape[1], img.shape[1] - x) 319 | 320 | # Exit if nothing to do 321 | if y1 >= y2 or x1 >= x2 or y1o >= y2o or x1o >= x2o: 322 | return 323 | 324 | # Blend overlay within the determined ranges 325 | img_crop = img[y1:y2, x1:x2] 326 | img_overlay_crop = img_overlay_res[y1o:y2o, x1o:x2o] 327 | alpha = alpha_mask[y1o:y2o, x1o:x2o, np.newaxis] 328 | alpha_inv = 1.0 - alpha 329 | 330 | img_crop[:] = alpha * img_overlay_crop + alpha_inv * img_crop 331 | 332 | @staticmethod 333 | def overlay_srt_line(fast, img, line, font_size, left_offset): 334 | if fast: 335 | return Utils.overlay_srt_line_fast(img, line, font_size, left_offset) 336 | else: 337 | return Utils.overlay_srt_line_slow(img, line, font_size, left_offset) 338 | 339 | @staticmethod 340 | def overlay_srt_line_slow(img, line, font_size, left_offset): 341 | pos_calc = (left_offset, img.shape[0] - 15) 342 | pil_im = Image.fromarray(img) 343 | draw = ImageDraw.Draw(pil_im, 'RGBA') 344 | try: 345 | font = ImageFont.truetype("font.ttf", font_size) 346 | except OSError: 347 | folder, _ = os.path.split(__file__) 348 | font = ImageFont.truetype(f"{folder}/resources/font.ttf") 349 | 350 | draw.text(pos_calc, line, font=font, fill=( 351 | 255, 255, 255, 255), anchor="lb") 352 | return Utils.to_numpy(pil_im) 353 | 354 | # pos_calc = (20, img.shape[0] - 30) 355 | # cv2.putText(img, line, pos_calc, cv2.FONT_ITALIC, 1/10 * font_size, (255, 255, 255, 255), 1) 356 | 357 | # return img 358 | 359 | @staticmethod 360 | def overlay_srt_line_fast(img, line, font_size, left_offset): 361 | left_offset = 200 if img.shape[1] > 1300 else 100 362 | pos_calc = (left_offset, img.shape[0] - 30) 363 | cv2.putText(img, line, pos_calc, cv2.FONT_HERSHEY_COMPLEX, 1/40 * font_size, (255, 255, 255, 255), 1) 364 | 365 | return img 366 | @staticmethod 367 | def to_numpy(im): 368 | im.load() 369 | # unpack data 370 | e = Image._getencoder(im.mode, 'raw', im.mode) 371 | e.setimage(im.im) 372 | 373 | # NumPy buffer for the result 374 | shape, typestr = Image._conv_type_shape(im) 375 | data = np.empty(shape, dtype=np.dtype(typestr)) 376 | mem = data.data.cast('B', (data.data.nbytes,)) 377 | 378 | bufsize, s, offset = 65536, 0, 0 379 | while not s: 380 | l, s, d = e.encode(bufsize) 381 | mem[offset:offset + len(d)] = d 382 | offset += len(d) 383 | if s < 0: 384 | raise RuntimeError("encoder error %d in tobytes" % s) 385 | return data 386 | 387 | @staticmethod 388 | def concatenate_output_files(output_files: list, final_path: str) -> None: 389 | """ 390 | Concatenate FFMPEG files without re-encoding. Current implementation 391 | requires a temporary file with paths listed, which is created. In the 392 | future this would ideally just be a long ffmpeg string 393 | """ 394 | cmd = ['ffmpeg', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', 395 | '-c', 'copy', final_path] 396 | try: 397 | os.remove('file_list.txt') 398 | except OSError: 399 | pass 400 | try: 401 | with open('file_list.txt', 'w') as fp: 402 | for file in output_files: 403 | fp.write(f"file '{file}'\n") 404 | subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=False) 405 | except subprocess.CalledProcessError as exc: 406 | print(exc.output.decode()) 407 | print(exc.returncode) 408 | finally: 409 | os.remove('file_list.txt') 410 | 411 | 412 | class OsdPreview: 413 | 414 | def __init__(self, config: OsdGenConfig): 415 | self.stopped = False 416 | 417 | self.font = OsdFont(config.font_path) 418 | self.osd = OSDFile(config.osd_path, self.font) 419 | self.video = VideoFile(config.video_path) 420 | if config.srt_path: 421 | self.srt = SrtFile(config.srt_path) 422 | else: 423 | self.srt = None 424 | self.output = config.output_path 425 | self.config = config 426 | 427 | def str_line_to_glyphs(self, line): 428 | filler = self.font.get_glyph(32) 429 | rssi = self.font.get_glyph(1) 430 | glyphs = [filler, rssi] 431 | for char in line: 432 | gi = ord(char) 433 | g = self.font.get_glyph(gi) 434 | glyphs.append(g) 435 | 436 | for x in range(len(glyphs), 53): 437 | glyphs.append(filler) 438 | 439 | return glyphs[:53] 440 | 441 | def generate_preview(self, osd_pos, osd_zomm): 442 | 443 | video_frame = self.video.read_frame().data 444 | 445 | for skipme in range(20): 446 | self.osd.read_frame() 447 | if self.srt: 448 | srt_data = self.srt.next_data() 449 | 450 | osd_frame_glyphs = self.osd.read_frame().get_osd_frame_glyphs( 451 | hide=self.config.hide_sensitive_osd) 452 | 453 | osd_frame = cv2.vconcat([cv2.hconcat(im_list_h) 454 | for im_list_h in osd_frame_glyphs]) 455 | if self.srt and self.config.include_srt: 456 | srt_line = srt_data["line"] 457 | video_frame = Utils.overlay_srt_line( 458 | self.config.fast_srt, video_frame, srt_line, self.font.get_srt_font_size(), (150 if self.font.is_hd() else 100)) 459 | Utils.overlay_image_alpha( 460 | video_frame, osd_frame, osd_pos[0], osd_pos[1], osd_zomm) 461 | result = cv2.resize(video_frame, (640, 360), 462 | interpolation=cv2.INTER_AREA) 463 | result = cv2.cvtColor(result, cv2.COLOR_BGR2RGB) 464 | 465 | return result 466 | # return result 467 | 468 | 469 | class SrtFile(): 470 | def __init__(self, path): 471 | self.index = 0 472 | with open(path, "r") as f: 473 | self.subs = list(srt.parse(f, True)) 474 | 475 | def next_data(self) -> dict: 476 | if self.index >= len(self.subs): 477 | self.index = len(self.subs) - 1 478 | sub = self.subs[self.index] 479 | data = dict(x.split(":") for x in sub.content.split(" ")) 480 | d = dict() 481 | d["startTime"] = sub.start.seconds * 1000 + sub.start.microseconds / 1000 482 | d["data"] = data # sub.start.seconds / 1000 * sub.start.microseconds 483 | d["line"] = "Signal:%1s Delay:%5s Bitrate:%7s Distance:%5s" % ( 484 | data["Signal"], data["Delay"], data["Bitrate"], data["Distance"]) 485 | self.index += 1 486 | return d 487 | 488 | 489 | class ThreadPoolExecutorWithQueueSizeLimit(ThreadPoolExecutor): 490 | def __init__(self, maxsize=50, *args, **kwargs): 491 | super(ThreadPoolExecutorWithQueueSizeLimit, 492 | self).__init__(*args, **kwargs) 493 | self._work_queue = queue.Queue(maxsize=maxsize) 494 | 495 | 496 | @dataclass() 497 | class CodecItem: 498 | supported_os: list 499 | name: str 500 | 501 | @dataclass 502 | class CodecsList: 503 | codecs: list[CodecItem] = field(default_factory=list) 504 | 505 | def getbyOS(self, os_name: str) -> list[CodecItem]: 506 | return list(filter(lambda codec: os_name in codec.supported_os, self.codecs)) 507 | 508 | 509 | class OsdGenerator: 510 | 511 | def __init__(self, config: OsdGenConfig): 512 | self.stopped = False 513 | 514 | self.font = OsdFont(config.font_path) 515 | self.osd = OSDFile(config.osd_path, self.font) 516 | self.video = VideoFile(config.video_path) 517 | self.output = config.output_path 518 | self.config = config 519 | self.osdGenStatus = OsdGenStatus() 520 | self.render_done = False 521 | self.use_hw = config.use_hw 522 | self.use_x264 = True 523 | self.codecs = CodecsList(self.load_codecs()) 524 | 525 | if config.srt_path: 526 | self.srt = SrtFile(config.srt_path) 527 | else: 528 | self.srt = None 529 | self.osdGenStatus.update(0, self.video.get_total_frames(), 0) 530 | try: 531 | os.mkdir(self.output) 532 | except: 533 | pass 534 | 535 | def load_codecs(self): 536 | 537 | macos = "darwin" 538 | windows = "windows" 539 | linux = "linux" 540 | codecs = [] 541 | 542 | if self.use_x264: 543 | if self.use_hw: 544 | codecs.append(CodecItem(name="h264_videotoolbox", supported_os=[macos])) 545 | codecs.append(CodecItem(name="h264_nvenc", supported_os=[windows, linux])) 546 | codecs.append(CodecItem(name="h264_amf", supported_os=[windows])) 547 | codecs.append(CodecItem(name="h264_vaapi", supported_os=[linux])) 548 | codecs.append(CodecItem(name="h264_qsv", supported_os=[linux, windows])) 549 | codecs.append(CodecItem(name="h264_mf", supported_os=[windows])) 550 | codecs.append(CodecItem(name="h264_v4l2m2m", supported_os=[linux])) 551 | 552 | codecs.append(CodecItem(name="libx264", supported_os=[macos, windows, linux])) 553 | else: 554 | if self.use_hw: 555 | codecs.append(CodecItem(name="hevc_videotoolbox", supported_os=[macos])) 556 | codecs.append(CodecItem(name="hevc_nvenc", supported_os=[windows, linux])) 557 | codecs.append(CodecItem(name="hevc_amf", supported_os=[windows])) 558 | codecs.append(CodecItem(name="hevc_vaapi", supported_os=[linux])) 559 | codecs.append(CodecItem(name="hevc_qsv", supported_os=[linux, windows])) 560 | codecs.append(CodecItem(name="hevc_mf", supported_os=[windows])) 561 | codecs.append(CodecItem(name="hevc_v4l2m2m", supported_os=[linux])) 562 | codecs.append(CodecItem(name="libx265", supported_os=[macos, windows, linux])) 563 | 564 | codecs.append(CodecItem(name="libx265", supported_os=[macos, windows, linux])) 565 | 566 | return codecs 567 | 568 | def get_working_encoder(self): 569 | available_codecs = self.codecs.getbyOS(platform.system().lower()) 570 | run_line = "ffmpeg -y -hwaccel auto -f lavfi -i nullsrc -c:v %s -frames:v 1 -f null -" 571 | for codec in available_codecs: 572 | runme = (run_line % codec.name).split(" ") 573 | ret = subprocess.run(runme, 574 | stdout=subprocess.DEVNULL, 575 | stderr=subprocess.DEVNULL) 576 | if ret.returncode == 0: 577 | logging.info("Found a working codec (%s)" % codec.name) 578 | return codec.name 579 | 580 | raise Exception("There is no valid codedc. It should not happen") 581 | 582 | def start_video(self, upscale: bool): 583 | Thread(target=self.render, args=()).start() 584 | return self 585 | 586 | def start(self): 587 | Thread(target=self.main, args=()).start() 588 | return self 589 | 590 | def stop(self): 591 | self.stopped = True 592 | 593 | @staticmethod 594 | def __render_osd_frame(osd_frame_glyphs): 595 | render = cv2.vconcat([cv2.hconcat(im_list_h) 596 | for im_list_h in osd_frame_glyphs]) 597 | return render 598 | 599 | def __overlay_osd(self, video_frame, osd_frame): 600 | 601 | alpha_mask = osd_frame[:, :, 3] / 255.0 602 | img_overlay = osd_frame[:, :, :3] 603 | h, w, a = osd_frame.shape 604 | hh, ww, aa = video_frame.shape 605 | xoff = round((ww - w) / 2) 606 | self.overlay_image_alpha(video_frame, img_overlay, xoff, 0, alpha_mask) 607 | 608 | return video_frame 609 | 610 | def render(self): 611 | self.osdGenStatus.update(0, 1, 0) 612 | 613 | video_size = self.video.get_size() 614 | if self.config.render_upscale: 615 | ff_size = {"w": 2560, "h": 1440} 616 | else: 617 | ff_size = {"w": video_size[1], "h": video_size[0]} 618 | 619 | out_path = os.path.join(self.output, "ws_%09d.png") 620 | osd_frame = ( 621 | ffmpeg 622 | .input(out_path, framerate=60) 623 | .filter("scale", **ff_size, force_original_aspect_ratio=0) 624 | 625 | ) 626 | 627 | input_args = { 628 | "hwaccel": "auto", 629 | } 630 | 631 | video = ( 632 | ffmpeg 633 | .input(self.config.video_path, **input_args) 634 | .filter("scale", **ff_size, force_original_aspect_ratio=1, ) 635 | ) 636 | encoder_name = self.get_working_encoder() 637 | output_args = { 638 | "c:v": encoder_name, 639 | "preset": "fast", 640 | "crf": 0, 641 | "b:v": "40M", 642 | "acodec": "copy" 643 | } 644 | self.render_done = False 645 | process = ( 646 | video 647 | .filter("pad", **ff_size, x=-1, y=-1, color="black") 648 | .overlay(osd_frame, x=0, y=0) 649 | .output("%s_osd.mp4" % (self.output), **output_args) 650 | .overwrite_output() 651 | .run() 652 | ) 653 | self.render_done = True 654 | 655 | def main(self): 656 | cps = CountsPerSec().start() 657 | pr = cProfile.Profile() 658 | pr.enable() 659 | 660 | osd_time = -1 661 | osd_frame = [] 662 | current_frame = 1 663 | srt_time = -1 664 | video_fps = self.video.get_fps() 665 | total_frames = self.video.get_total_frames() 666 | video_size = self.video.get_size() 667 | img_height, img_width = video_size[0], video_size[1] 668 | n_channels = 4 669 | transparent_img = np.zeros( 670 | (img_height, img_width, n_channels), dtype=np.uint8) 671 | frame = transparent_img.copy() 672 | executor = ThreadPoolExecutorWithQueueSizeLimit( 673 | max_workers=multiprocessing.cpu_count()-1, maxsize=2000) 674 | 675 | while True: 676 | if self.stopped: 677 | print("Process canceled.") 678 | break 679 | 680 | frames_per_ms = 1 / video_fps * 1000 681 | calc_video_time = int((current_frame - 1) * frames_per_ms) 682 | 683 | if current_frame >= total_frames: 684 | break 685 | 686 | if osd_time < calc_video_time: 687 | raw_osd_frame = self.osd.read_frame() 688 | if not raw_osd_frame: 689 | break 690 | frame = transparent_img.copy() 691 | osd_frame = self.__render_osd_frame( 692 | raw_osd_frame.get_osd_frame_glyphs(hide=self.config.hide_sensitive_osd)) 693 | osd_time = raw_osd_frame.startTime 694 | Utils.merge_images(frame, osd_frame, self.config.offset_left, 695 | self.config.offset_top, self.config.osd_zoom) 696 | osd_frame_no_srt = frame 697 | 698 | if self.srt and self.config.include_srt: 699 | if srt_time < calc_video_time: 700 | srt_data = self.srt.next_data() 701 | srt_time = srt_data["startTime"] 702 | 703 | frame_osd_srt = Utils.overlay_srt_line(self.config.fast_srt, osd_frame_no_srt, srt_data["line"], self.font.get_srt_font_size( 704 | ), (150 if self.font.is_hd() else 100)) 705 | result = frame_osd_srt 706 | else: 707 | result = osd_frame_no_srt 708 | 709 | # logging.debug(f"frame':{current_frame},'total':{total_frames},'srt':{srt_time},'osd':{osd_time},'video':{calc_video_time}") 710 | out_path = os.path.join(self.output, "ws_%09d.png" % (current_frame)) 711 | executor.submit(cv2.imwrite, out_path, result) 712 | 713 | current_frame += 1 714 | cps.increment() 715 | fps = int(cps.countsPerSec()) 716 | self.osdGenStatus.update(current_frame - 1, total_frames, fps) 717 | 718 | if current_frame % 200 == 0: 719 | logging.debug("Current: %s/%s (fps: %d)" % 720 | (current_frame, total_frames, fps)) 721 | 722 | logging.info("Waiting for jobs to complete") 723 | executor.shutdown(cancel_futures=False, wait=True) 724 | logging.info("Save complete") 725 | self.osdGenStatus.update(total_frames, total_frames, fps) 726 | pr.disable() 727 | s = io.StringIO() 728 | sortby = SortKey.CUMULATIVE 729 | ps = pstats.Stats(pr, stream=s).sort_stats(sortby) 730 | ps.print_stats() 731 | logging.debug(s.getvalue()) 732 | -------------------------------------------------------------------------------- /requirements-noui.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/requirements-noui.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/requirements.txt -------------------------------------------------------------------------------- /resources/ffmpeg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/ffmpeg.exe -------------------------------------------------------------------------------- /resources/font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/font.ttf -------------------------------------------------------------------------------- /resources/user_ardu_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/user_ardu_24.png -------------------------------------------------------------------------------- /resources/user_ardu_36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/user_ardu_36.png -------------------------------------------------------------------------------- /resources/user_bf_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/user_bf_24.png -------------------------------------------------------------------------------- /resources/user_bf_36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kirek007/ws-osd-py/2804ccd43f6056af4dac427fb5465813dee26eab/resources/user_bf_36.png -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | from processor import OsdGenStatus, OsdGenerator, OsdGenConfig 5 | 6 | 7 | class AppState: 8 | 9 | def __init__(self) -> None: 10 | self._video_path = "" 11 | self._osd_path = "" 12 | self._font_path = "" 13 | self._output_path = "" 14 | self._srt_path = "" 15 | self._osd_gen = None 16 | self._include_srt = False 17 | self._hide_sensitive_osd = False 18 | self._use_hw = False 19 | self._fast_srt = True 20 | 21 | self.offsetLeft = 0 22 | self.offsetTop = 0 23 | self.osdZoom = 100 24 | 25 | self.render_upscale = False 26 | 27 | def updateOsdPosition(self, left, top, zoom): 28 | self.offsetLeft = left 29 | self.offsetTop = top 30 | self.osdZoom = zoom 31 | 32 | def getOptionsByPath(self, path: str): 33 | file_ext = pathlib.Path(path).suffix 34 | 35 | if file_ext in {".osd", ".mp4", ".srt"}: 36 | video = os.fspath(pathlib.Path(path).with_suffix('.mp4')) 37 | srt = os.fspath(pathlib.Path(path).with_suffix('.srt')) 38 | osd = os.fspath(pathlib.Path(path).with_suffix('.osd')) 39 | if os.path.exists(video): 40 | self._video_path = video 41 | if os.path.exists(srt): 42 | self._srt_path = srt 43 | if os.path.exists(osd): 44 | self._osd_path = osd 45 | self.update_output_path(path) 46 | elif file_ext == ".png": 47 | self._font_path = path 48 | else: 49 | pass 50 | 51 | def update_output_path(self, path: str): 52 | self._output_path = os.path.join( 53 | path, "%s_generated" % os.path.splitext(path)[0]) 54 | 55 | def is_output_exists(self): 56 | return os.path.exists(self._output_path) 57 | 58 | def is_configured(self) -> bool: 59 | if (self._font_path and self._osd_path and self._video_path and not self.is_output_exists()): 60 | return True 61 | else: 62 | return False 63 | 64 | def osd_cancel_process(self): 65 | if self._osd_gen: 66 | self._osd_gen.stop() 67 | 68 | def osd_gen_status(self) -> OsdGenStatus: 69 | return self._osd_gen.osdGenStatus 70 | 71 | def get_osd_config(self) -> OsdGenConfig: 72 | return OsdGenConfig( 73 | self._video_path, 74 | self._osd_path, 75 | self._font_path, 76 | self._srt_path, 77 | self._output_path, 78 | self.offsetLeft, 79 | self.offsetTop, 80 | self.osdZoom, 81 | self.render_upscale, 82 | self._include_srt, 83 | self._hide_sensitive_osd, 84 | self._use_hw, 85 | self._fast_srt 86 | ) 87 | 88 | def osd_init(self) -> OsdGenStatus: 89 | self._osd_gen = OsdGenerator(self.get_osd_config()) 90 | return self.osd_gen_status() 91 | 92 | def osd_start_process(self): 93 | self._osd_gen.start() 94 | 95 | def osd_render_video(self): 96 | self._osd_gen.start_video(False) 97 | 98 | def osd_reset(self): 99 | self._osd_gen = None 100 | 101 | def get_osd(self): 102 | return self._osd_gen 103 | 104 | 105 | appState = AppState() 106 | -------------------------------------------------------------------------------- /ws_osd_gen.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis( 8 | ['osd_gui.py'], 9 | pathex=[], 10 | binaries=[], 11 | datas=[], 12 | hiddenimports=[], 13 | hookspath=[], 14 | hooksconfig={}, 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False, 21 | ) 22 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 23 | 24 | exe = EXE( 25 | pyz, 26 | a.scripts, 27 | a.binaries, 28 | a.zipfiles, 29 | a.datas, 30 | [], 31 | name='ws_osd_gen', 32 | debug=False, 33 | bootloader_ignore_signals=False, 34 | strip=False, 35 | upx=True, 36 | upx_exclude=[], 37 | runtime_tmpdir=None, 38 | console=True, 39 | disable_windowed_traceback=False, 40 | argv_emulation=False, 41 | target_arch=None, 42 | codesign_identity=None, 43 | entitlements_file=None, 44 | ) 45 | --------------------------------------------------------------------------------