├── .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 |
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 | 
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 |
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 |
--------------------------------------------------------------------------------