-- HUD Overlay Outline Script for OBS (separate Create / Update buttons) -- New approach: detect the green symbols first, then draw them in a configurable -- color (preset list x brightness), so they stay readable over ANY background -- (bright sky, green terrain, clouds). -- Principle: -- 1. symbol detection: subtract a black level, alpha = max(R, G, B) x gain, -- multiplied by a greenness factor (G - max(R, B)) to reject gray/white noise -- 2. optional outline: sample the symbol alpha in a ring around each pixel; pixels -- near a symbol get the outline color (default blue, x outline brightness) -- underneath the fill -- 3. premultiplied output: symbol color x symbol alpha + outline color x outline alpha -- Requires: obs-shaderfilter plugin (installed) -- Install: OBS -> Tools -> Scripts -> + -> select this file -- "Create Scene" : builds the scene from scratch (deletes and recreates both media sources) -- "Update Scene" : updates the existing scene in place (video paths, color, keying parameters) obs = obslua local SETTING_FRONT_PATH = "front_path" local SETTING_BACK_PATH = "back_path" local SETTING_COLOR = "hud_color" -- preset color index local SETTING_BRIGHTNESS = "symbol_brightness" local SETTING_BLACK_LEVEL = "black_level" local SETTING_GAIN = "alpha_gain" local SETTING_GREEN_THRESH = "green_thresh" local SETTING_OUT_WIDTH = "outline_width" local SETTING_OUT_ALPHA = "outline_alpha" local SETTING_OUT_COLOR = "outline_color" -- outline preset color index (1 = blue) local SETTING_OPACITY = "opacity" local SCENE_NAME = "HUD Overlay Outline" local FRONT_NAME = "HUD - Front Outline" local BACK_NAME = "Center IG - Back Outline" local FILTER_NAME = "HUD Outline" -- Preset symbol colors at full brightness; the Brightness slider scales the chosen color local COLOR_PRESETS = { { name = "Pure green", r = 0.0, g = 1.0, b = 0.0 }, { name = "HUD green (measured from source video)", r = 0.15, g = 1.0, b = 0.10 }, { name = "Bright green (neon green)", r = 0.25, g = 1.0, b = 0.1 }, { name = "Red (complementary to teal sky)", r = 1.0, g = 0.0, b = 0.0 }, { name = "Magenta", r = 1.0, g = 0.0, b = 1.0 }, { name = "Orange", r = 1.0, g = 0.5, b = 0.0 }, { name = "Yellow", r = 1.0, g = 1.0, b = 0.0 }, { name = "White", r = 1.0, g = 1.0, b = 1.0 }, { name = "Black", r = 0.0, g = 0.0, b = 0.0 }, } -- Preset outline colors; index 1 is blue (the outline default) local OUTLINE_COLOR_PRESETS = { { name = "Blue", r = 0.0, g = 0.0, b = 1.0 }, { name = "Green", r = 0.0, g = 1.0, b = 0.0 }, { name = "Cyan", r = 0.0, g = 1.0, b = 1.0 }, { name = "White", r = 1.0, g = 1.0, b = 1.0 }, { name = "Black", r = 0.0, g = 0.0, b = 0.0 }, { name = "Red", r = 1.0, g = 0.0, b = 0.0 }, { name = "Yellow", r = 1.0, g = 1.0, b = 0.0 }, } local front_path = "" local back_path = "" local color_index = 1 local outline_color_index = 1 local symbol_brightness = 1.0 local black_level = 0.055 -- 14/255, measured noise floor of the HUD capture local alpha_gain = 1.35 local green_thresh = 0.10 -- greenness (G - max(R,B)) needed to count as a green symbol local outline_width = 0.0 -- stroke width adjustment in source pixels: + thicker, - thinner, 0 = original local outline_alpha = 0.9 local opacity = 1.0 local fit_retries = 0 -- Symbol-detect + outline shader: -- sym_alpha(uv): black level subtraction -> brightness key x gain -> greenness gate -- main: symbol = pure green x sym_alpha; ring of neighbor sym_alpha forms the outline local SHADER_TEXT = [==[ uniform float black_level < string label = "Black level"; string widget_type = "slider"; float minimum = 0.0; float maximum = 0.3; float step = 0.001; > = 0.055; uniform float alpha_gain < string label = "Alpha gain"; string widget_type = "slider"; float minimum = 0.5; float maximum = 4.0; float step = 0.01; > = 1.35; uniform float green_thresh < string label = "Green threshold"; string widget_type = "slider"; float minimum = 0.01; float maximum = 0.5; float step = 0.005; > = 0.10; uniform float outline_width < string label = "Symbol width (px, + thicker / - thinner)"; string widget_type = "slider"; float minimum = -4.0; float maximum = 8.0; float step = 0.5; > = 0.0; uniform float outline_alpha < string label = "Outline strength"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 0.9; uniform float hud_r < string label = "Symbol color R"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 0.0; uniform float hud_g < string label = "Symbol color G"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 1.0; uniform float hud_b < string label = "Symbol color B"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 0.0; uniform float outline_brightness < string label = "Outline brightness"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 1.0; uniform float outline_r < string label = "Outline color R"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 0.0; uniform float outline_g < string label = "Outline color G"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 0.0; uniform float outline_b < string label = "Outline color B"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 1.0; uniform float opacity < string label = "Opacity"; string widget_type = "slider"; float minimum = 0.0; float maximum = 1.0; float step = 0.01; > = 1.0; // Symbol alpha at a given UV: 1 = solid green symbol, 0 = background/noise float sym_alpha(float2 uv) { float4 px = image.Sample(textureSampler, uv); px.rgb *= (px.a > 0.0) ? (1.0 / px.a) : 0.0; // drop the codec noise floor; pure black becomes exactly 0 float3 h = max(px.rgb - black_level, 0.0); // brightness key float k = max(h.r, max(h.g, h.b)); // greenness gate: green symbols pass, gray/white noise is rejected float gn = h.g - max(h.r, h.b); float gf = clamp(gn / green_thresh, 0.0, 1.0); return clamp(k * alpha_gain, 0.0, 1.0) * gf; } float4 mainImage(VertData v_in) : TARGET { float a_sym = sym_alpha(v_in.uv); // Symbol width: positive = dilate (thicker strokes), negative = erode (thinner) float2 d = uv_pixel_interval * abs(outline_width); float2 dg = d * 0.7071; float a_out = 0.0; if (outline_width > 0.0) { // Ring max -> same-color outline under the fill = thicker strokes float ring = 0.0; ring = max(ring, sym_alpha(v_in.uv + float2( d.x, 0.0))); ring = max(ring, sym_alpha(v_in.uv + float2(-d.x, 0.0))); ring = max(ring, sym_alpha(v_in.uv + float2( 0.0, d.y))); ring = max(ring, sym_alpha(v_in.uv + float2( 0.0, -d.y))); ring = max(ring, sym_alpha(v_in.uv + float2( dg.x, dg.y))); ring = max(ring, sym_alpha(v_in.uv + float2(-dg.x, dg.y))); ring = max(ring, sym_alpha(v_in.uv + float2( dg.x, -dg.y))); ring = max(ring, sym_alpha(v_in.uv + float2(-dg.x, -dg.y))); // Outline only where the symbol itself is not already opaque a_out = min(ring * outline_alpha, 1.0 - a_sym); } else if (outline_width < 0.0) { // Ring min -> erode the strokes thinner float core = 1.0; core = min(core, sym_alpha(v_in.uv + float2( d.x, 0.0))); core = min(core, sym_alpha(v_in.uv + float2(-d.x, 0.0))); core = min(core, sym_alpha(v_in.uv + float2( 0.0, d.y))); core = min(core, sym_alpha(v_in.uv + float2( 0.0, -d.y))); core = min(core, sym_alpha(v_in.uv + float2( dg.x, dg.y))); core = min(core, sym_alpha(v_in.uv + float2(-dg.x, dg.y))); core = min(core, sym_alpha(v_in.uv + float2( dg.x, -dg.y))); core = min(core, sym_alpha(v_in.uv + float2(-dg.x, -dg.y))); a_sym = min(a_sym, core); } float a_sym_o = a_sym * opacity; float a_out_o = a_out * opacity; // Premultiplied: symbol color over an outline in its own color (x outline brightness) float3 rgb = float3(hud_r, hud_g, hud_b) * a_sym_o + float3(outline_r, outline_g, outline_b) * outline_brightness * a_out_o; return float4(rgb, a_sym_o + a_out_o); } ]==] ------------------------------------------------------------ -- Utility functions ------------------------------------------------------------ -- Matches the exact base name or an OBS auto-numbered duplicate (" 2", " 3", ...) local function source_name_matches(name, base) if name == base then return true end return name:sub(1, #base) == base and name:sub(#base + 1):match("^%s*%d+$") ~= nil end -- Finds a source by base name, falling back to auto-numbered duplicates. -- Returns nil or a reference the caller must release. local function get_source_by_base_name(base) local exact = obs.obs_get_source_by_name(base) if exact ~= nil then return exact end local found = nil local sources = obs.obs_enum_sources() if sources ~= nil then for _, s in ipairs(sources) do if source_name_matches(obs.obs_source_get_name(s), base) then found = obs.obs_source_get_ref(s) break end end obs.source_list_release(sources) end return found end -- Removes every source matching the base name, including duplicates left over -- from earlier runs. The scene items are removed first so the sources are -- destroyed immediately; otherwise OBS keeps them alive under the same name -- and renames newly created sources to " 2", " 3", ... local function remove_sources_by_base_name(scene, base) local items = obs.obs_scene_enum_items(scene) for _, item in ipairs(items) do local s = obs.obs_sceneitem_get_source(item) if s ~= nil and source_name_matches(obs.obs_source_get_name(s), base) then obs.obs_sceneitem_remove(item) end end obs.sceneitem_list_release(items) local sources = obs.obs_enum_sources() if sources ~= nil then for _, s in ipairs(sources) do if source_name_matches(obs.obs_source_get_name(s), base) then obs.obs_source_remove(s) end end obs.source_list_release(sources) end end local function create_media_source(name, path) local s = obs.obs_data_create() obs.obs_data_set_string(s, "local_file", path) obs.obs_data_set_bool(s, "is_local_file", true) obs.obs_data_set_bool(s, "looping", true) obs.obs_data_set_bool(s, "restart_on_activate", true) obs.obs_data_set_bool(s, "close_when_inactive", false) local src = obs.obs_source_create("ffmpeg_source", name, s, nil) obs.obs_data_release(s) return src end -- Point an existing media source at a new file without recreating it local function update_media_source_path(src, path) local s = obs.obs_source_get_settings(src) obs.obs_data_set_string(s, "local_file", path) obs.obs_source_update(src, s) obs.obs_data_release(s) end local function apply_key_filter(src) local old = obs.obs_source_get_filter_by_name(src, FILTER_NAME) if old ~= nil then obs.obs_source_filter_remove(src, old) obs.obs_source_release(old) end local preset = COLOR_PRESETS[color_index] or COLOR_PRESETS[1] local outline_preset = OUTLINE_COLOR_PRESETS[outline_color_index] or OUTLINE_COLOR_PRESETS[1] local fs = obs.obs_data_create() obs.obs_data_set_bool(fs, "from_file", false) obs.obs_data_set_string(fs, "shader_text", SHADER_TEXT) obs.obs_data_set_double(fs, "hud_r", preset.r * symbol_brightness) obs.obs_data_set_double(fs, "hud_g", preset.g * symbol_brightness) obs.obs_data_set_double(fs, "hud_b", preset.b * symbol_brightness) obs.obs_data_set_double(fs, "outline_r", outline_preset.r) obs.obs_data_set_double(fs, "outline_g", outline_preset.g) obs.obs_data_set_double(fs, "outline_b", outline_preset.b) obs.obs_data_set_double(fs, "black_level", black_level) obs.obs_data_set_double(fs, "alpha_gain", alpha_gain) obs.obs_data_set_double(fs, "green_thresh", green_thresh) obs.obs_data_set_double(fs, "outline_width", outline_width) obs.obs_data_set_double(fs, "outline_alpha", outline_alpha) obs.obs_data_set_double(fs, "opacity", opacity) -- First check whether the obs-shaderfilter plugin is loaded if obs.obs_source_get_display_name("shader_filter") == nil then obs.obs_data_release(fs) obs.script_log(obs.LOG_WARNING, "HUD Overlay Outline: obs-shaderfilter plugin not detected; keying was not applied. Please fully quit OBS (including the system tray) and restart it.") return end local f = obs.obs_source_create("shader_filter", FILTER_NAME, fs, nil) obs.obs_data_release(fs) if f ~= nil then obs.obs_source_filter_add(src, f) obs.obs_source_release(f) end end local function fit_items_to_canvas() local vi = obs.obs_video_info() if obs.obs_get_video_info(vi) == false then return false end local cw, ch = vi.base_width, vi.base_height local scene_src = obs.obs_get_source_by_name(SCENE_NAME) if scene_src == nil then return false end local scene = obs.obs_scene_from_source(scene_src) local items = obs.obs_scene_enum_items(scene) local all_ready = true for _, item in ipairs(items) do local s = obs.obs_sceneitem_get_source(item) local w = obs.obs_source_get_width(s) local h = obs.obs_source_get_height(s) if w > 0 and h > 0 then local scale = math.min(cw / w, ch / h) local sc = obs.vec2() sc.x = scale sc.y = scale obs.obs_sceneitem_set_scale(item, sc) local pos = obs.vec2() pos.x = (cw - w * scale) / 2 pos.y = (ch - h * scale) / 2 obs.obs_sceneitem_set_pos(item, pos) -- Use Lanczos when upscaling line-art footage; sharper than the default bilinear if obs.obs_sceneitem_set_scale_filter ~= nil then obs.obs_sceneitem_set_scale_filter(item, obs.OBS_SCALE_LANCZOS or 4) end else all_ready = false end end obs.sceneitem_list_release(items) obs.obs_source_release(scene_src) return all_ready end local function fit_retry() fit_retries = fit_retries - 1 if fit_items_to_canvas() or fit_retries <= 0 then obs.timer_remove(fit_retry) end end local function schedule_fit() fit_retries = 10 obs.timer_remove(fit_retry) obs.timer_add(fit_retry, 500) end ------------------------------------------------------------ -- Create scene (from scratch) ------------------------------------------------------------ local function create_scene() if front_path == "" or back_path == "" then obs.script_log(obs.LOG_WARNING, "HUD Overlay Outline: please set the foreground and background video paths first") return end local scene = nil local scene_src = obs.obs_get_source_by_name(SCENE_NAME) if scene_src ~= nil then scene = obs.obs_scene_from_source(scene_src) else scene = obs.obs_scene_create(SCENE_NAME) scene_src = obs.obs_scene_get_source(scene) end remove_sources_by_base_name(scene, FRONT_NAME) remove_sources_by_base_name(scene, BACK_NAME) local back = create_media_source(BACK_NAME, back_path) obs.obs_scene_add(scene, back) local front = create_media_source(FRONT_NAME, front_path) apply_key_filter(front) obs.obs_scene_add(scene, front) obs.obs_source_release(back) obs.obs_source_release(front) obs.obs_frontend_set_current_scene(scene_src) obs.obs_source_release(scene_src) schedule_fit() obs.script_log(obs.LOG_INFO, "HUD Overlay Outline: scene created") end ------------------------------------------------------------ -- Update scene (in place, without recreating sources) ------------------------------------------------------------ local function update_scene() if front_path == "" or back_path == "" then obs.script_log(obs.LOG_WARNING, "HUD Overlay Outline: please set the foreground and background video paths first") return end local scene_src = obs.obs_get_source_by_name(SCENE_NAME) if scene_src == nil then obs.script_log(obs.LOG_WARNING, "HUD Overlay Outline: scene \"" .. SCENE_NAME .. "\" does not exist yet; please use \"Create Scene\" first") return end local back = get_source_by_base_name(BACK_NAME) local front = get_source_by_base_name(FRONT_NAME) if back == nil or front == nil then if back ~= nil then obs.obs_source_release(back) end if front ~= nil then obs.obs_source_release(front) end obs.obs_source_release(scene_src) obs.script_log(obs.LOG_WARNING, "HUD Overlay Outline: media sources are missing; please use \"Create Scene\" to rebuild the scene") return end -- Update video files in place update_media_source_path(back, back_path) update_media_source_path(front, front_path) -- Re-apply the keying filter with the current keying parameters apply_key_filter(front) obs.obs_source_release(back) obs.obs_source_release(front) obs.obs_frontend_set_current_scene(scene_src) obs.obs_source_release(scene_src) schedule_fit() obs.script_log(obs.LOG_INFO, "HUD Overlay Outline: scene updated") end local function on_create_clicked(props, p) create_scene() return true end local function on_update_clicked(props, p) update_scene() return true end ------------------------------------------------------------ -- Script UI and settings ------------------------------------------------------------ function script_description() return "Detects the green HUD symbols and draws them in a configurable color and brightness,\n" .. "so they stay readable over any background (bright sky, green terrain, clouds).\n" .. "Symbol detection: alpha = max(R, G, B) x gain after black-level subtraction, gated by greenness G - max(R, B) to reject noise.\n" .. "\"Symbol color\" picks the hue, \"Symbol brightness\" scales it (0.35 = dark green, 1.0 = full brightness).\n" .. "\"Symbol width\" thickens (positive) or thins (negative) the strokes; 0 keeps the original width.\n" .. "\"Outline color\" picks the outline hue (default blue); the outline only appears when \"Symbol width\" is positive.\n" .. "\"Create Scene\": builds the scene from scratch (deletes and recreates both media sources).\n" .. "\"Update Scene\": updates the existing scene in place (video paths, color, keying parameters).\n" .. "The filter panel has sliders for fine-tuning (symbol color R/G/B, outline color R/G/B, width, strength and brightness)." end function script_properties() local props = obs.obs_properties_create() obs.obs_properties_add_path(props, SETTING_FRONT_PATH, "Foreground video (HUD, green symbols on black)", obs.OBS_PATH_FILE, "Video files (*.avi *.mp4 *.mov *.mkv *.webm);;All files (*.*)", "") obs.obs_properties_add_path(props, SETTING_BACK_PATH, "Background video (bottom layer)", obs.OBS_PATH_FILE, "Video files (*.avi *.mp4 *.mov *.mkv *.webm);;All files (*.*)", "") local clist = obs.obs_properties_add_list(props, SETTING_COLOR, "Symbol color", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT) for i, c in ipairs(COLOR_PRESETS) do obs.obs_property_list_add_int(clist, c.name, i) end obs.obs_properties_add_float_slider(props, SETTING_BRIGHTNESS, "Symbol brightness (scales the chosen color)", 0.05, 1.0, 0.01) obs.obs_properties_add_float_slider(props, SETTING_BLACK_LEVEL, "Black level (increase if a dark halo/noise remains)", 0.0, 0.3, 0.001) obs.obs_properties_add_float_slider(props, SETTING_GAIN, "Alpha gain (increase if symbols look too transparent)", 0.5, 4.0, 0.01) obs.obs_properties_add_float_slider(props, SETTING_GREEN_THRESH, "Green threshold (increase if non-green noise appears)", 0.01, 0.5, 0.005) obs.obs_properties_add_float_slider(props, SETTING_OUT_WIDTH, "Symbol width (px, + thicker / - thinner, 0 = original)", -4.0, 8.0, 0.5) obs.obs_properties_add_float_slider(props, SETTING_OUT_ALPHA, "Outline strength", 0.0, 1.0, 0.01) local olist = obs.obs_properties_add_list(props, SETTING_OUT_COLOR, "Outline color (only visible when Symbol width > 0)", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT) for i, c in ipairs(OUTLINE_COLOR_PRESETS) do obs.obs_property_list_add_int(olist, c.name, i) end obs.obs_properties_add_float_slider(props, SETTING_OPACITY, "Overall opacity", 0.0, 1.0, 0.01) obs.obs_properties_add_button(props, "create_btn", "Create Scene", on_create_clicked) obs.obs_properties_add_button(props, "update_btn", "Update Scene", on_update_clicked) return props end function script_defaults(settings) obs.obs_data_set_default_string(settings, SETTING_FRONT_PATH, "D:\\Test Pictures + Video\\HUD - Front Video.avi") obs.obs_data_set_default_string(settings, SETTING_BACK_PATH, "D:\\Test Pictures + Video\\Center IG - Back Videos.avi") obs.obs_data_set_default_int(settings, SETTING_COLOR, 1) obs.obs_data_set_default_double(settings, SETTING_BRIGHTNESS, 1.0) obs.obs_data_set_default_double(settings, SETTING_BLACK_LEVEL, 0.055) obs.obs_data_set_default_double(settings, SETTING_GAIN, 1.35) obs.obs_data_set_default_double(settings, SETTING_GREEN_THRESH, 0.10) obs.obs_data_set_default_double(settings, SETTING_OUT_WIDTH, 0.0) obs.obs_data_set_default_double(settings, SETTING_OUT_ALPHA, 0.9) obs.obs_data_set_default_int(settings, SETTING_OUT_COLOR, 1) obs.obs_data_set_default_double(settings, SETTING_OPACITY, 1.0) end function script_update(settings) front_path = obs.obs_data_get_string(settings, SETTING_FRONT_PATH) back_path = obs.obs_data_get_string(settings, SETTING_BACK_PATH) color_index = obs.obs_data_get_int(settings, SETTING_COLOR) outline_color_index = obs.obs_data_get_int(settings, SETTING_OUT_COLOR) symbol_brightness = obs.obs_data_get_double(settings, SETTING_BRIGHTNESS) black_level = obs.obs_data_get_double(settings, SETTING_BLACK_LEVEL) alpha_gain = obs.obs_data_get_double(settings, SETTING_GAIN) green_thresh = obs.obs_data_get_double(settings, SETTING_GREEN_THRESH) outline_width = obs.obs_data_get_double(settings, SETTING_OUT_WIDTH) outline_alpha = obs.obs_data_get_double(settings, SETTING_OUT_ALPHA) opacity = obs.obs_data_get_double(settings, SETTING_OPACITY) end