diff --git a/misc/camera_feed/README.md b/misc/camera_feed/README.md new file mode 100644 index 0000000000..2a46f8e308 --- /dev/null +++ b/misc/camera_feed/README.md @@ -0,0 +1,43 @@ +# Camera Feed Demo + +A demo that shows how to display live camera feeds from various sources +using Godot's [CameraFeed](https://docs.godotengine.org/en/stable/classes/class_camerafeed.html) +and [CameraServer](https://docs.godotengine.org/en/stable/classes/class_cameraserver.html) APIs. +Supports multiple platforms including desktop, mobile, and web browsers. + +Language: GDScript + +Renderer: Compatibility, Mobile, Forward+ + +> Note: this demo requires Godot 4.5 or later + +# How does it work? + +The demo uses `CameraServer` to enumerate available camera devices and display their feeds in real-time. Key features include: + +1. **Camera Detection**: Automatically detects all available camera feeds using `CameraServer.feeds()`. + +2. **Platform Support**: + - Handles camera permissions on mobile platforms (Android/iOS) + - Supports web browsers with special monitoring setup + - Works on desktop platforms with standard camera APIs + +3. **Feed Formats**: + - RGB format for standard color feeds + - YCbCr format with shader-based conversion for certain devices + - Dynamic format selection based on camera capabilities + +4. **Real-time Display**: + - Uses `CameraTexture` to display live camera feeds + - Handles camera rotation and orientation transforms + - Maintains proper aspect ratio for different camera resolutions + +5. **Shader Processing**: + - Custom shader (`ycbcr_to_rgb.gdshader`) converts YCbCr feeds to RGB + - Uses BT.709 color space conversion standard for HDTV + +The UI provides controls to select cameras, choose formats, and start/stop the feed display. + +## Screenshots + +![Screenshot](screenshots/camera_feed.png) diff --git a/misc/camera_feed/camerafeed.gd b/misc/camera_feed/camerafeed.gd new file mode 100644 index 0000000000..45e2c8392b --- /dev/null +++ b/misc/camera_feed/camerafeed.gd @@ -0,0 +1,206 @@ +extends Control + +@onready var camera_display := $CameraDisplay +@onready var camera_preview := $CameraDisplay/CameraPreview +@onready var camera_list := $DrawerContainer/Drawer/DrawerContent/VBoxContainer/CameraList +@onready var format_list := $DrawerContainer/Drawer/DrawerContent/VBoxContainer/FormatList +@onready var start_or_stop_button := $DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer/StartOrStopButton +@onready var reload_button := $DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer/ReloadButton + +var camera_feed: CameraFeed + +const defaultWebResolution: Dictionary = { + "width": 640, + "height": 480, +} + +func _ready() -> void: + _adjust_ui() + _reload_camera_list() + + +func _adjust_ui() -> void: + camera_display.size = camera_display.get_parent_area_size() - Vector2.ONE * 40 + camera_preview.custom_minimum_size = camera_display.size + camera_preview.position = camera_display.size / 2 + + +func _reload_camera_list() -> void: + camera_list.clear() + format_list.clear() + + var os_name := OS.get_name() + # Request camera permission on mobile. + if os_name in ["Android", "iOS"]: + var permissions = OS.get_granted_permissions() + if not "CAMERA" in permissions: + if not OS.request_permission("CAMERA"): + print("CAMERA permission not granted") + return + + if not CameraServer.camera_feeds_updated.is_connected(_on_camera_feeds_updated): + CameraServer.camera_feeds_updated.connect(_on_camera_feeds_updated) + + if CameraServer.monitoring_feeds: + CameraServer.monitoring_feeds = false + await get_tree().process_frame + + CameraServer.monitoring_feeds = true + + +func _on_camera_feeds_updated() -> void: + # Get available camera feeds. + var feeds = CameraServer.feeds() + if feeds.is_empty(): + camera_list.add_item("No cameras found") + camera_list.disabled = true + format_list.add_item("No formats available") + format_list.disabled = true + start_or_stop_button.disabled = true + return + + camera_list.disabled = false + for i in feeds.size(): + var feed: CameraFeed = feeds[i] + camera_list.add_item(feed.get_name()) + + # Auto-select first camera. + camera_list.selected = 0 + _on_camera_list_item_selected(0) + + +func _on_camera_list_item_selected(index: int) -> void: + var camera_feeds := CameraServer.feeds() + if index < 0 or index >= camera_feeds.size(): + return + + # Stop previous camera if active. + if camera_feed and camera_feed.feed_is_active: + camera_feed.feed_is_active = false + + # Get selected camera feed. + camera_feed = camera_feeds[index] + + # Update format list. + _update_format_list() + + +func _update_format_list() -> void: + format_list.clear() + + if not camera_feed: + return + + var formats = camera_feed.get_formats() + if formats.is_empty(): + format_list.add_item("No formats available") + format_list.disabled = true + start_or_stop_button.disabled = true + return + + format_list.disabled = false + for format in formats: + var resolution := str(format["width"]) + "x" + str(format["height"]) + var item := "%s - %s" % [format["format"], resolution] + if OS.get_name() == "Windows": + item += " : %s / %s" % [format["frame_denominator"], format["frame_numerator"]] + format_list.add_item(item) + + # Auto-select first format. + format_list.selected = 0 + _on_format_list_item_selected(0) + + +func _on_format_list_item_selected(index: int) -> void: + if not camera_feed: + return + + var formats := camera_feed.get_formats() + if index < 0 or index >= formats.size(): + return + var os_name := OS.get_name() + var parameters: Dictionary = defaultWebResolution if os_name == "Web" else {} + camera_feed.set_format(index, parameters) + _start_camera_feed() + + +func _start_camera_feed() -> void: + if not camera_feed: + return + + if not camera_feed.frame_changed.is_connected(_on_frame_changed): + camera_feed.frame_changed.connect(_on_frame_changed, ConnectFlags.CONNECT_ONE_SHOT) + # Start the feed. + camera_feed.feed_is_active = true + + +func _on_frame_changed() -> void: + var datatype := camera_feed.get_datatype() as CameraFeed.FeedDataType + var preview_size := Vector2.ZERO + + var mat: ShaderMaterial = camera_preview.material + var rgb_texture: CameraTexture = mat.get_shader_parameter("rgb_texture") + var y_texture: CameraTexture = mat.get_shader_parameter("y_texture") + var cbcr_texture: CameraTexture = mat.get_shader_parameter("cbcr_texture") + var ycbcr_texture: CameraTexture = mat.get_shader_parameter("ycbcr_texture") + + rgb_texture.which_feed = CameraServer.FeedImage.FEED_RGBA_IMAGE + y_texture.which_feed = CameraServer.FeedImage.FEED_Y_IMAGE + cbcr_texture.which_feed = CameraServer.FeedImage.FEED_CBCR_IMAGE + ycbcr_texture.which_feed = CameraServer.FEED_YCBCR_IMAGE + + match datatype: + CameraFeed.FeedDataType.FEED_RGB: + rgb_texture.camera_feed_id = camera_feed.get_id() + mat.set_shader_parameter("rgb_texture", rgb_texture) + mat.set_shader_parameter("mode", 0) + preview_size = rgb_texture.get_size() + CameraFeed.FeedDataType.FEED_YCBCR_SEP: + y_texture.camera_feed_id = camera_feed.get_id() + cbcr_texture.camera_feed_id = camera_feed.get_id() + mat.set_shader_parameter("y_texture", y_texture) + mat.set_shader_parameter("cbcr_texture", cbcr_texture) + mat.set_shader_parameter("mode", 1) + preview_size = y_texture.get_size() + CameraFeed.FeedDataType.FEED_YCBCR: + ycbcr_texture.camera_feed_id = camera_feed.get_id() + mat.set_shader_parameter("ycbcr_texture", ycbcr_texture) + mat.set_shader_parameter("mode", 2) + preview_size = ycbcr_texture.get_size() + _: + print("Skip formats that are not supported.") + return + + var white_image := Image.create(int(preview_size.x), int(preview_size.y), false, Image.FORMAT_RGBA8) + white_image.fill(Color.WHITE) + camera_preview.texture = ImageTexture.create_from_image(white_image) + + var rot := camera_feed.feed_transform.get_rotation() + var degree := roundi(rad_to_deg(rot)) + camera_preview.rotation = rot + camera_preview.custom_minimum_size.y = camera_display.size.y + + if degree % 180 == 0: + camera_display.ratio = preview_size.x / preview_size.y + else: + camera_display.ratio = preview_size.y / preview_size.x + + start_or_stop_button.text = "Stop" + + +func _on_start_or_stop_button_pressed(change_label: bool = true) -> void: + if camera_feed and camera_feed.feed_is_active: + camera_feed.feed_is_active = false + camera_preview.texture = null + camera_preview.rotation = 0 + if change_label: + start_or_stop_button.text = "Start" + else: + _start_camera_feed() + if change_label: + start_or_stop_button.text = "Stop" + + +func _on_reload_button_pressed() -> void: + _on_start_or_stop_button_pressed(false) + _reload_camera_list() diff --git a/misc/camera_feed/camerafeed.gd.uid b/misc/camera_feed/camerafeed.gd.uid new file mode 100644 index 0000000000..a5abe064d4 --- /dev/null +++ b/misc/camera_feed/camerafeed.gd.uid @@ -0,0 +1 @@ +uid://dxaoavn781kxe diff --git a/misc/camera_feed/camerafeed.tscn b/misc/camera_feed/camerafeed.tscn new file mode 100644 index 0000000000..9b085076a0 --- /dev/null +++ b/misc/camera_feed/camerafeed.tscn @@ -0,0 +1,164 @@ +[gd_scene load_steps=11 format=3 uid="uid://oiv4p8ii3am4"] + +[ext_resource type="Script" uid="uid://dxaoavn781kxe" path="res://camerafeed.gd" id="1_fuswq"] +[ext_resource type="Shader" uid="uid://dhjh7s6i7jnlp" path="res://ycbcr_to_rgb.gdshader" id="2_0uyi5"] + +[sub_resource type="CameraTexture" id="CameraTexture_7c2aw"] + +[sub_resource type="CameraTexture" id="CameraTexture_nyeft"] + +[sub_resource type="CameraTexture" id="CameraTexture_xep8u"] + +[sub_resource type="CameraTexture" id="CameraTexture_fuswq"] + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_lgiw1"] +shader = ExtResource("2_0uyi5") +shader_parameter/rgb_texture = SubResource("CameraTexture_nyeft") +shader_parameter/y_texture = SubResource("CameraTexture_xep8u") +shader_parameter/cbcr_texture = SubResource("CameraTexture_7c2aw") +shader_parameter/ycbcr_texture = SubResource("CameraTexture_fuswq") +shader_parameter/mode = 0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1"] +bg_color = Color(0.15, 0.15, 0.15, 0.95) +corner_radius_top_left = 20 +corner_radius_top_right = 20 +shadow_color = Color(0, 0, 0, 0.3) +shadow_size = 5 +shadow_offset = Vector2(0, -2) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2"] +bg_color = Color(0.2, 0.2, 0.2, 1) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3"] +bg_color = Color(0.3, 0.3, 0.3, 1) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 + +[node name="CameraApp" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_fuswq") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0, 0, 0, 1) + +[node name="CameraDisplay" type="AspectRatioContainer" parent="."] +layout_mode = 0 +offset_left = 20.0 +offset_top = 20.0 +offset_right = 700.0 +offset_bottom = 1260.0 +stretch_mode = 1 + +[node name="CameraPreview" type="TextureRect" parent="CameraDisplay"] +material = SubResource("ShaderMaterial_lgiw1") +layout_mode = 2 +stretch_mode = 5 + +[node name="DrawerContainer" type="Control" parent="."] +modulate = Color(1, 1, 1, 0.5019608) +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_top = 160.0 +offset_bottom = 160.0 +grow_horizontal = 2 + +[node name="Drawer" type="PanelContainer" parent="DrawerContainer"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_1") + +[node name="DrawerContent" type="MarginContainer" parent="DrawerContainer/Drawer"] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 20 + +[node name="VBoxContainer" type="VBoxContainer" parent="DrawerContainer/Drawer/DrawerContent"] +layout_mode = 2 +theme_override_constants/separation = 15 + +[node name="HandleBar" type="Control" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 + +[node name="Bar" type="ColorRect" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer/HandleBar"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -30.0 +offset_top = -2.0 +offset_right = 30.0 +offset_bottom = 2.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.5, 0.5, 0.5, 1) + +[node name="ButtonContainer" type="HBoxContainer" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="StartOrStopButton" type="Button" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(0, 50) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_styles/normal = SubResource("StyleBoxFlat_2") +theme_override_styles/pressed = SubResource("StyleBoxFlat_2") +theme_override_styles/hover = SubResource("StyleBoxFlat_3") +text = "Stop" + +[node name="ReloadButton" type="Button" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer"] +custom_minimum_size = Vector2(0, 50) +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_styles/normal = SubResource("StyleBoxFlat_2") +theme_override_styles/pressed = SubResource("StyleBoxFlat_2") +theme_override_styles/hover = SubResource("StyleBoxFlat_3") +text = "Reload" + +[node name="CameraLabel" type="Label" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +layout_mode = 2 +text = "Camera" + +[node name="CameraList" type="OptionButton" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 + +[node name="FormatLabel" type="Label" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +layout_mode = 2 +text = "Format" + +[node name="FormatList" type="OptionButton" parent="DrawerContainer/Drawer/DrawerContent/VBoxContainer"] +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 + +[connection signal="pressed" from="DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer/StartOrStopButton" to="." method="_on_start_or_stop_button_pressed"] +[connection signal="pressed" from="DrawerContainer/Drawer/DrawerContent/VBoxContainer/ButtonContainer/ReloadButton" to="." method="_on_reload_button_pressed"] +[connection signal="item_selected" from="DrawerContainer/Drawer/DrawerContent/VBoxContainer/CameraList" to="." method="_on_camera_list_item_selected"] +[connection signal="item_selected" from="DrawerContainer/Drawer/DrawerContent/VBoxContainer/FormatList" to="." method="_on_format_list_item_selected"] diff --git a/misc/camera_feed/icon.svg b/misc/camera_feed/icon.svg new file mode 100644 index 0000000000..b370ceb727 --- /dev/null +++ b/misc/camera_feed/icon.svg @@ -0,0 +1 @@ + diff --git a/misc/camera_feed/icon.svg.import b/misc/camera_feed/icon.svg.import new file mode 100644 index 0000000000..e8f93aa7f8 --- /dev/null +++ b/misc/camera_feed/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cq1vjnvnae2tl" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/misc/camera_feed/project.godot b/misc/camera_feed/project.godot new file mode 100644 index 0000000000..a44026b3ee --- /dev/null +++ b/misc/camera_feed/project.godot @@ -0,0 +1,42 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[android] + +permissions=["CAMERA"] + +[application] + +config/name="CameraFeed Demo" +run/main_scene="uid://oiv4p8ii3am4" +config/features=PackedStringArray("4.5") +run/low_processor_mode=true +config/icon="res://icon.svg" +config/run/main_scene="res://camerafeed.tscn" + +[display] + +window/size/viewport_width=720 +window/size/viewport_height=1280 +window/size/window_width_override=480 +window/size/window_height_override=854 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" +window/handheld/orientation=1 + +[ios] + +privacy/camera_usage_description="This app requires camera access to function properly." + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/vram_compression/import_etc2_astc=true diff --git a/misc/camera_feed/screenshots/camera_feed.png b/misc/camera_feed/screenshots/camera_feed.png new file mode 100644 index 0000000000..29610eff33 Binary files /dev/null and b/misc/camera_feed/screenshots/camera_feed.png differ diff --git a/misc/camera_feed/ycbcr_to_rgb.gdshader b/misc/camera_feed/ycbcr_to_rgb.gdshader new file mode 100644 index 0000000000..d4e21097a4 --- /dev/null +++ b/misc/camera_feed/ycbcr_to_rgb.gdshader @@ -0,0 +1,38 @@ +shader_type canvas_item; + +uniform sampler2D rgb_texture; +// Y component texture (Feed ID 1 -> FEED_Y_IMAGE) +uniform sampler2D y_texture; +// CbCr component texture (Feed ID 2 -> FEED_CBCR_IMAGE) +uniform sampler2D cbcr_texture; +// YCbCr component texture (Feed ID 1 -> FEED_YCBCR_IMAGE) +uniform sampler2D ycbcr_texture; +// mode: 0 -> RGB, mode: 1 -> YCbCr_sep, mode: 2 -> YCbCr +uniform int mode : hint_range(0, 2); + +// YCbCr to RGB conversion (BT.601 standard) +void fragment() { + vec3 color; + + if (mode == 1) { + color.r = texture(y_texture, UV).r; + color.gb = texture(cbcr_texture, UV).rg - vec2(0.5, 0.5); + } else if (mode == 2) { + vec2 UV_u = UV - floor(mod(UV / TEXTURE_PIXEL_SIZE, 2)) * vec2(1, 0) * TEXTURE_PIXEL_SIZE; + vec2 UV_v = UV + (vec2(1, 0) - floor(mod(UV / TEXTURE_PIXEL_SIZE, 2))) * vec2(1, 0) * TEXTURE_PIXEL_SIZE; + color.r = texture(ycbcr_texture, UV).r; + color.g = texture(ycbcr_texture, UV_u).g - 0.5; + color.b = texture(ycbcr_texture, UV_v).g - 0.5; + } + + // YCbCr -> SRGB conversion + // Using BT.709 which is the standard for HDTV + color.rgb = mat3( + vec3(1.00000, 1.00000, 1.00000), + vec3(0.00000, -0.18732, 1.85560), + vec3(1.57481, -0.46813, 0.00000)) + * color.rgb; + + vec3 rgb = texture(rgb_texture, UV).rgb; + COLOR = vec4(mix(rgb, color, clamp(float(mode), 0.0, 1.0)), 1.0); +} diff --git a/misc/camera_feed/ycbcr_to_rgb.gdshader.uid b/misc/camera_feed/ycbcr_to_rgb.gdshader.uid new file mode 100644 index 0000000000..0cefc02e04 --- /dev/null +++ b/misc/camera_feed/ycbcr_to_rgb.gdshader.uid @@ -0,0 +1 @@ +uid://dhjh7s6i7jnlp