浏览代码

新增:按键映射板块新增 图片映射功能,可以通过关键词或者礼物触发,将本地图片添加到虚拟摄像头用于直播使用(默认随机路径)

ikaros 4 天之前
父节点
当前提交
160da9bd8b
共有 6 个文件被更改,包括 262 次插入98 次删除
  1. 31 10
      config.json
  2. 31 10
      config.json.bak
  3. 17 0
      main.py
  4. 31 3
      utils/my_handle.py
  5. 100 43
      utils/sd.py
  6. 52 32
      webui.py

文件差异内容过多而无法显示
+ 31 - 10
config.json


文件差异内容过多而无法显示
+ 31 - 10
config.json.bak


+ 17 - 0
main.py

@@ -151,6 +151,23 @@ def start_server():
         os._exit(0)
 
     if platform != "wxlive":
+        """
+
+                  /@@@@@@@@          @@@@@@@@@@@@@@@].      =@@@@@@@       
+                 =@@@@@@@@@^         @@@@@@@@@@@@@@@@@@`    =@@@@@@@       
+                ,@@@@@@@@@@@`        @@@@@@@@@@@@@@@@@@@^   =@@@@@@@       
+               .@@@@@@\@@@@@@.       @@@@@@@^   .\@@@@@@\   =@@@@@@@       
+               /@@@@@/ \@@@@@\       @@@@@@@^    =@@@@@@@   =@@@@@@@       
+              =@@@@@@. .@@@@@@^      @@@@@@@\]]]@@@@@@@@^   =@@@@@@@       
+             ,@@@@@@^   =@@@@@@`     @@@@@@@@@@@@@@@@@@/    =@@@@@@@       
+            .@@@@@@@@@@@@@@@@@@@.    @@@@@@@@@@@@@@@@/`     =@@@@@@@       
+            /@@@@@@@@@@@@@@@@@@@\    @@@@@@@^               =@@@@@@@       
+           =@@@@@@@@@@@@@@@@@@@@@^   @@@@@@@^               =@@@@@@@       
+          ,@@@@@@@.       ,@@@@@@@`  @@@@@@@^               =@@@@@@@       
+          @@@@@@@^         =@@@@@@@. @@@@@@@^               =@@@@@@@   
+
+        """
+        
         # HTTP API线程
         def http_api_thread():
             import uvicorn

+ 31 - 3
utils/my_handle.py

@@ -488,6 +488,12 @@ class My_handle(metaclass=SingletonMeta):
             from utils.sd import SD
 
             self.sd = SD(My_handle.config.get("sd"))
+        # 特殊:在SD没有使能情况下,判断图片映射是否使能
+        elif My_handle.config.get("key_mapping", "img_path_trigger_type") != "不启用":
+            # 沿用SD的虚拟摄像头来展示图片
+            from utils.sd import SD
+
+            self.sd = SD({"enable": False, "visual_camera": My_handle.config.get("sd", "visual_camera")})
 
         # 日志文件路径
         self.log_file_path = "./log/log-" + My_handle.common.get_bj_time(1) + ".txt"
@@ -2439,6 +2445,20 @@ class My_handle(metaclass=SingletonMeta):
                 logger.error(traceback.format_exc())
                 return None
 
+        # 获取一个本地图片路径并传递给虚拟摄像头显示
+        def get_a_img_path_and_send(key_mapping_config, data):
+            try:
+                # 随机获取一个图片路径
+                if len(key_mapping_config["img_path"]) <= 0:
+                    return
+                
+                tmp = random.choice(key_mapping_config["img_path"])
+
+                self.sd.set_new_img(tmp)
+            except Exception as e:
+                logger.error(traceback.format_exc())
+
+
         try:
             import pyautogui
 
@@ -2461,6 +2481,9 @@ class My_handle(metaclass=SingletonMeta):
                         elif trigger_type == "serial_trigger_type":
                             logger.info(f'【触发按键映射】关键词:{keyword} ,触发串口')
                             get_a_serial_send_data_and_send(key_mapping_config, data)
+                        elif trigger_type == "img_path_trigger_type":
+                            logger.info(f'【触发按键映射】关键词:{keyword} ,触发图片')
+                            get_a_img_path_and_send(key_mapping_config, data)
                         
                         flag = True
                         
@@ -2490,7 +2513,10 @@ class My_handle(metaclass=SingletonMeta):
                         elif trigger_type == "serial_trigger_type":
                             logger.info(f'【触发按键映射】礼物:{gift_name} ,触发串口')
                             get_a_serial_send_data_and_send(key_mapping_config, data)
-                        
+                        elif trigger_type == "img_path_trigger_type":
+                            logger.info(f'【触发按键映射】礼物:{gift_name} ,触发图片')
+                            get_a_img_path_and_send(key_mapping_config, data)
+
                         flag = True
                         
                     single_sentence_trigger_once_enable = My_handle.config.get("key_mapping", f"{trigger_type.split('_')[0]}_single_sentence_trigger_once_enable")
@@ -2541,7 +2567,8 @@ class My_handle(metaclass=SingletonMeta):
                                     不同的触发类型 都会进行独立的执行判断
                                     """
                                     
-                                    for trigger in ["key_trigger_type", "copywriting_trigger_type", "local_audio_trigger_type", "serial_trigger_type"]:
+                                    for trigger in ["key_trigger_type", "copywriting_trigger_type", "local_audio_trigger_type", "serial_trigger_type", \
+                                                    "img_path_trigger_type"]:
                                         resp_json = keyword_handle_trigger(trigger, keyword, key_mapping_config, data, flag)
                                         if resp_json["trigger_once_enable"]:
                                             return resp_json["flag"]  
@@ -2549,7 +2576,8 @@ class My_handle(metaclass=SingletonMeta):
                             elif type == "回复":
                                 logger.debug(f"keyword={keyword}, content={content}")
                                 if keyword in content:
-                                    for trigger in ["key_trigger_type", "copywriting_trigger_type", "local_audio_trigger_type", "serial_trigger_type"]:
+                                    for trigger in ["key_trigger_type", "copywriting_trigger_type", "local_audio_trigger_type", "serial_trigger_type", \
+                                                    "img_path_trigger_type"]:
                                         resp_json = keyword_handle_trigger(trigger, keyword, key_mapping_config, data, flag)
                                         if resp_json["trigger_once_enable"]:
                                             return resp_json["flag"]

+ 100 - 43
utils/sd.py

@@ -3,12 +3,25 @@ import webuiapi
 # from PIL import Image
 import pyvirtualcam
 import numpy as np
-import time
-import asyncio, os
+import traceback
+import asyncio
+import os
+from PIL import Image, ImageOps
+import numpy as np
 
 from .common import Common
 from .my_log import logger
 
+def hex_to_rgba(hex_str):
+    """将十六进制颜色字符串转换为 RGBA 元组."""
+    hex_str = hex_str.lstrip('#')
+    if len(hex_str) == 8:
+        return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4, 6))  # 分别提取RGBA
+    elif len(hex_str) == 6:
+        return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4)) + (255,)  # RGB + 默认不透明度
+    else:
+        logger.error(f"无效的颜色值: {hex_str}")
+        raise ValueError(f"无效的颜色值: {hex_str}")
 
 class SD:
     def __init__(self, data): 
@@ -18,37 +31,70 @@ class SD:
         self.sd_config = data
 
         try:
-            # 创建 API 客户端
-            self.api = webuiapi.WebUIApi(host=data["ip"], port=data["port"])
+            if data["enable"]:
+                # 创建 API 客户端
+                self.api = webuiapi.WebUIApi(host=data["ip"], port=data["port"])
+
+            self.rgba_color = hex_to_rgba(data["visual_camera"]["background_color"])
 
+            logger.info("即将创建 虚拟摄像头线程...")
             # 在单独的线程中更新虚拟摄像头
             threading.Thread(target=lambda: asyncio.run(self.update_virtual_camera())).start()
             # threading.Thread(target=self.update_virtual_camera).start()
         except Exception as e:
-            logger.error(e)
+            logger.error(traceback.format_exc())
 
     async def update_virtual_camera(self):
-        # 创建虚拟摄像头
-        with pyvirtualcam.Camera(width=512, height=512, fps=1) as cam:
-            logger.info(f'SD创建的虚拟摄像头为: 【{cam.device}】')
-
-            while True:
-                if self.new_img is not None:
-                    # 调整图像尺寸以匹配虚拟摄像头的分辨率
-                    resized_img = self.new_img.resize((cam.width, cam.height))
-
-                    # 将 PIL 图像转换为 numpy 数组并设置数据类型为 uint8
-                    frame = np.array(resized_img)
-                    frame = frame.astype(np.uint8)
-
-                    # 将图像帧发送到虚拟摄像头
-                    cam.send(frame)
-
-                    # 等待下一帧
-                    # cam.sleep_until_next_frame()
-
-                # 暂停一段时间
-                await asyncio.sleep(0.1)
+        try:
+            # 固定虚拟摄像头的分辨率
+            cam_width, cam_height = 1920, 1080  # 这里可以根据需要修改分辨率
+            with pyvirtualcam.Camera(width=cam_width, height=cam_height, fps=1, fmt=pyvirtualcam.PixelFormat.RGB) as cam:
+                logger.info(f'虚拟摄像头已创建,分辨率:{cam_width}x{cam_height},设备:{cam.device}')
+
+                while True:
+                    if self.new_img is not None:
+                        try:
+                            # 获取图片原始宽高
+                            img_width, img_height = self.new_img.size
+                            
+                            # 计算图片的缩放比例,确保图片按比例缩放并且适应虚拟摄像头的宽或高
+                            scale = min(cam_width / img_width, cam_height / img_height)
+                            new_size = (int(img_width * scale), int(img_height * scale))
+                            
+                            # 调整图片大小
+                            resized_img = self.new_img.resize(new_size, Image.LANCZOS)
+
+                            # 检查是否有 Alpha 通道,如果没有则添加
+                            if resized_img.mode != 'RGBA':
+                                resized_img = resized_img.convert('RGBA')
+                            
+                            # 创建一个带自定义背景的空白图像,大小与摄像头一致
+                            custom_background = Image.new('RGBA', (cam_width, cam_height), self.rgba_color)
+                            
+                            # 计算居中位置
+                            paste_position = ((cam_width - new_size[0]) // 2, (cam_height - new_size[1]) // 2)
+                            
+                            # 将调整后的图片粘贴到自定义背景的中心
+                            custom_background.paste(resized_img, paste_position, resized_img)
+                            
+                            # 将图像转换为RGB(去除Alpha通道)
+                            rgb_img = custom_background.convert('RGB')
+                            
+                            # 将 PIL 图像转换为 numpy 数组并设置数据类型为 uint8
+                            frame = np.array(rgb_img).astype(np.uint8)
+
+                            # 将图像帧发送到虚拟摄像头
+                            cam.send(frame)
+
+                        except Exception as e:
+                            logger.error(traceback.format_exc())
+                            logger.error(f"更新虚拟摄像头失败:{e}")
+
+                    # 暂停一段时间
+                    await asyncio.sleep(0.1)
+        except Exception as e:
+            logger.error(traceback.format_exc())
+            logger.error(f"更新虚拟摄像头失败:{e}")
 
     def save_image_locally(self, img):
         # 确保有一个用于保存图片的目录
@@ -87,23 +133,24 @@ class SD:
             hr_resize_y:生成图像的垂直尺寸。
             denoising_strength:去噪强度,用于控制生成图像中的噪点。
         """
-        result = self.api.txt2img(prompt=user_input,
-            negative_prompt=self.sd_config["negative_prompt"],
-            seed=self.sd_config["seed"],
-            styles=self.sd_config["styles"],
-            cfg_scale=self.sd_config["cfg_scale"],
-            # sampler_index='DDIM',
-            steps=self.sd_config["steps"],
-            enable_hr=self.sd_config["enable_hr"],
-            hr_scale=self.sd_config["hr_scale"],
-            # hr_upscaler=webuiapi.HiResUpscaler.Latent,
-            hr_second_pass_steps=self.sd_config["hr_second_pass_steps"],
-            hr_resize_x=self.sd_config["hr_resize_x"],
-            hr_resize_y=self.sd_config["hr_resize_y"],
-            denoising_strength=self.sd_config["denoising_strength"],
-        )
-
         try:
+            result = self.api.txt2img(prompt=user_input,
+                negative_prompt=self.sd_config["negative_prompt"],
+                seed=self.sd_config["seed"],
+                styles=self.sd_config["styles"],
+                cfg_scale=self.sd_config["cfg_scale"],
+                # sampler_index='DDIM',
+                steps=self.sd_config["steps"],
+                enable_hr=self.sd_config["enable_hr"],
+                hr_scale=self.sd_config["hr_scale"],
+                # hr_upscaler=webuiapi.HiResUpscaler.Latent,
+                hr_second_pass_steps=self.sd_config["hr_second_pass_steps"],
+                hr_resize_x=self.sd_config["hr_resize_x"],
+                hr_resize_y=self.sd_config["hr_resize_y"],
+                denoising_strength=self.sd_config["denoising_strength"],
+            )
+
+        
             # 获取返回的图像
             img = result.image
             self.new_img = img
@@ -112,6 +159,16 @@ class SD:
             if self.sd_config["save_enable"]:
                 self.save_image_locally(img)
         except Exception as e:
-            logger.error(e)
+            logger.error(traceback.format_exc())
+            logger.error(f"调用 SD API 失败:{e}")
             return None
 
+    def set_new_img(self, img_path: str):
+        try:
+            # 读取图片
+            img = Image.open(img_path)
+            self.new_img = img
+        except Exception as e:
+            logger.error(traceback.format_exc())
+            logger.error(f"读取图片失败:{e}")
+            return None

+ 52 - 32
webui.py

@@ -1253,25 +1253,29 @@ def goto_func_page():
             "similarity": 1,
             "copywriting": [],
             "local_audio": [],
+            "img_path": []
         }
 
         with key_mapping_config_card.style(card_css):
             with ui.row():
-                key_mapping_config_var[str(data_len)] = ui.textarea(label=f"关键词#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["keywords"]), placeholder='此处输入触发的关键词,多个请以换行分隔').style("width:200px;")
-                key_mapping_config_var[str(data_len + 1)] = ui.textarea(label=f"礼物#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["gift"]), placeholder='此处输入触发的礼物名,多个请以换行分隔').style("width:200px;")
-                key_mapping_config_var[str(data_len + 2)] = ui.textarea(label=f"按键#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["keys"]), placeholder='此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)').style("width:100px;")
-                key_mapping_config_var[str(data_len + 3)] = ui.input(label=f"相似度#{int(data_len / 8) + 1}", value=tmp_config["similarity"], placeholder='关键词与用户输入的相似度,默认1即100%').style("width:50px;")
-                key_mapping_config_var[str(data_len + 4)] = ui.textarea(label=f"文案#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;")
-                key_mapping_config_var[str(data_len + 5)] = ui.textarea(label=f"文案#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;")
-                key_mapping_config_var[str(data_len + 6)] = ui.input(label=f"串口名#{int(data_len / 8) + 1}", value=tmp_config["serial_name"], placeholder='例如:COM1').style("width:100px;").tooltip('串口页配置的串口名,例如:COM1')
-                key_mapping_config_var[str(data_len + 7)] = ui.textarea(label=f"串口发送内容#{int(data_len / 8) + 1}", value=textarea_data_change(tmp_config["serial_send_data"]), placeholder='多个请以换行分隔,ASCII例如:open led\nHEX例如(2个字符的十六进制字符):313233').style("width:300px;").tooltip('此处输入发送到串口的数据内容,数据类型根据串口页设置决定,多个请以换行分隔')
+                num = int(data_len / 9) + 1
+                key_mapping_config_var[str(data_len)] = ui.textarea(label=f"关键词#{num}", value=textarea_data_change(tmp_config["keywords"]), placeholder='此处输入触发的关键词,多个请以换行分隔').style("width:100px;")
+                key_mapping_config_var[str(data_len + 1)] = ui.textarea(label=f"礼物#{num}", value=textarea_data_change(tmp_config["gift"]), placeholder='此处输入触发的礼物名,多个请以换行分隔').style("width:100px;")
+                key_mapping_config_var[str(data_len + 2)] = ui.textarea(label=f"按键#{num}", value=textarea_data_change(tmp_config["keys"]), placeholder='此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)').style("width:100px;")
+                key_mapping_config_var[str(data_len + 3)] = ui.input(label=f"相似度#{num}", value=tmp_config["similarity"], placeholder='关键词与用户输入的相似度,默认1即100%').style("width:50px;")
+                key_mapping_config_var[str(data_len + 4)] = ui.textarea(label=f"文案#{num}", value=textarea_data_change(tmp_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;")
+                key_mapping_config_var[str(data_len + 5)] = ui.textarea(label=f"文案#{num}", value=textarea_data_change(tmp_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;")
+                key_mapping_config_var[str(data_len + 6)] = ui.input(label=f"串口名#{num}", value=tmp_config["serial_name"], placeholder='例如:COM1').style("width:100px;").tooltip('串口页配置的串口名,例如:COM1')
+                key_mapping_config_var[str(data_len + 7)] = ui.textarea(label=f"串口发送内容#{num}", value=textarea_data_change(tmp_config["serial_send_data"]), placeholder='多个请以换行分隔,ASCII例如:open led\nHEX例如(2个字符的十六进制字符):313233').style("width:300px;").tooltip('此处输入发送到串口的数据内容,数据类型根据串口页设置决定,多个请以换行分隔')
+                key_mapping_config_var[str(data_len + 8)] = ui.textarea(label=f"串口发送内容#{num}", value=textarea_data_change(tmp_config["serial_send_data"]), placeholder='多个请以换行分隔,ASCII例如:open led\nHEX例如(2个字符的十六进制字符):313233').style("width:300px;").tooltip('此处输入发送到串口的数据内容,数据类型根据串口页设置决定,多个请以换行分隔')
                           
     
     def key_mapping_del(index):
         try:
+            num = 9
             key_mapping_config_card.remove(int(index) - 1)
             # 删除操作
-            keys_to_delete = [str(8 * (int(index) - 1) + i) for i in range(8)]
+            keys_to_delete = [str(num * (int(index) - 1) + i) for i in range(num)]
             for key in keys_to_delete:
                 if key in key_mapping_config_var:
                     del key_mapping_config_var[key]
@@ -1279,7 +1283,7 @@ def goto_func_page():
             # 重新编号剩余的键
             updates = {}
             for key in sorted(key_mapping_config_var.keys(), key=int):
-                new_key = str(int(key) - 8 if int(key) > int(keys_to_delete[-1]) else key)
+                new_key = str(int(key) - num if int(key) > int(keys_to_delete[-1]) else key)
                 updates[new_key] = key_mapping_config_var[key]
 
             # 应用更新
@@ -2064,11 +2068,17 @@ def goto_func_page():
                     config_data["key_mapping"]["local_audio_single_sentence_trigger_once"] = switch_key_mapping_local_audio_single_sentence_trigger_once_enable.value
                     config_data["key_mapping"]["serial_trigger_type"] = select_key_mapping_serial_trigger_type.value
                     config_data["key_mapping"]["serial_single_sentence_trigger_once"] = switch_key_mapping_serial_single_sentence_trigger_once_enable.value
+                    config_data["key_mapping"]["img_path_trigger_type"] = select_key_mapping_img_path_trigger_type.value
+                    config_data["key_mapping"]["img_path_single_sentence_trigger_once"] = switch_key_mapping_img_path_single_sentence_trigger_once_enable.value
                     
+
                     config_data["key_mapping"]["start_cmd"] = input_key_mapping_start_cmd.value
                     tmp_arr = []
                     # logger.info(key_mapping_config_var)
-                    for index in range(len(key_mapping_config_var) // 8):
+
+                    num = 9
+
+                    for index in range(len(key_mapping_config_var) // num):
                         tmp_json = {
                             "keywords": [],
                             "gift": [],
@@ -2077,15 +2087,17 @@ def goto_func_page():
                             "copywriting": [],
                             "serial_name": "",
                             "serial_send_data": [],
+                            "img_path": []
                         }
-                        tmp_json["keywords"] = common_textarea_handle(key_mapping_config_var[str(8 * index)].value)
-                        tmp_json["gift"] = common_textarea_handle(key_mapping_config_var[str(8 * index + 1)].value)
-                        tmp_json["keys"] = common_textarea_handle(key_mapping_config_var[str(8 * index + 2)].value)
-                        tmp_json["similarity"] = key_mapping_config_var[str(8 * index + 3)].value
-                        tmp_json["copywriting"] = common_textarea_handle(key_mapping_config_var[str(8 * index + 4)].value)
-                        tmp_json["local_audio"] = common_textarea_handle(key_mapping_config_var[str(8 * index + 5)].value)
-                        tmp_json["serial_name"] = key_mapping_config_var[str(8 * index + 6)].value
-                        tmp_json["serial_send_data"] = common_textarea_handle(key_mapping_config_var[str(8 * index + 7)].value)
+                        tmp_json["keywords"] = common_textarea_handle(key_mapping_config_var[str(num * index)].value)
+                        tmp_json["gift"] = common_textarea_handle(key_mapping_config_var[str(num * index + 1)].value)
+                        tmp_json["keys"] = common_textarea_handle(key_mapping_config_var[str(num * index + 2)].value)
+                        tmp_json["similarity"] = key_mapping_config_var[str(num * index + 3)].value
+                        tmp_json["copywriting"] = common_textarea_handle(key_mapping_config_var[str(num * index + 4)].value)
+                        tmp_json["local_audio"] = common_textarea_handle(key_mapping_config_var[str(num * index + 5)].value)
+                        tmp_json["serial_name"] = key_mapping_config_var[str(num * index + 6)].value
+                        tmp_json["serial_send_data"] = common_textarea_handle(key_mapping_config_var[str(num * index + 7)].value)
+                        tmp_json["img_path"] = common_textarea_handle(key_mapping_config_var[str(num * index + 8)].value)
 
                         tmp_arr.append(tmp_json)
                     # logger.info(tmp_arr)
@@ -3954,7 +3966,7 @@ def goto_func_page():
                 
                 if config.get("webui", "show_card", "common_config", "key_mapping"):  
                     with ui.card().style(card_css):
-                        ui.label('按键/文案/音频/串口 映射')
+                        ui.label('按键/文案/音频/串口/图片 映射')
                         with ui.row():
                             switch_key_mapping_enable = ui.switch('启用', value=config.get("key_mapping", "enable")).style(switch_internal_css)
                             input_key_mapping_start_cmd = ui.input(
@@ -3973,26 +3985,32 @@ def goto_func_page():
                                 label='按键触发类型',
                                 options={'不启用': '不启用', '关键词': '关键词', '礼物': '礼物', '关键词+礼物': '关键词+礼物'},
                                 value=config.get("key_mapping", "key_trigger_type")
-                            ).style("width:150px").tooltip('什么类型的数据会触发按键映射')
+                            ).style("width:120px").tooltip('什么类型的数据会触发按键映射')
                             switch_key_mapping_key_single_sentence_trigger_once_enable = ui.switch('单句仅触发一次(按键)', value=config.get("key_mapping", "key_single_sentence_trigger_once")).style(switch_internal_css).tooltip('一句话的数据,是否只让这句话触发一次按键映射,因为一句话中可能会有多个关键词,触发多次')
                             select_key_mapping_copywriting_trigger_type = ui.select(
                                 label='文案触发类型',
                                 options={'不启用': '不启用', '关键词': '关键词', '礼物': '礼物', '关键词+礼物': '关键词+礼物'},
                                 value=config.get("key_mapping", "copywriting_trigger_type")
-                            ).style("width:150px").tooltip('什么类型的数据会触发文案映射')
+                            ).style("width:120px").tooltip('什么类型的数据会触发文案映射')
                             switch_key_mapping_copywriting_single_sentence_trigger_once_enable = ui.switch('单句仅触发一次(文案)', value=config.get("key_mapping", "copywriting_single_sentence_trigger_once")).style(switch_internal_css).tooltip('一句话的数据,是否只让这句话触发一次文案映射,因为一句话中可能会有多个关键词,触发多次')
                             select_key_mapping_local_audio_trigger_type = ui.select(
                                 label='本地音频触发类型',
                                 options={'不启用': '不启用', '关键词': '关键词', '礼物': '礼物', '关键词+礼物': '关键词+礼物'},
                                 value=config.get("key_mapping", "local_audio_trigger_type")
-                            ).style("width:150px").tooltip('什么类型的数据会触发本地音频映射')
+                            ).style("width:120px").tooltip('什么类型的数据会触发本地音频映射')
                             switch_key_mapping_local_audio_single_sentence_trigger_once_enable = ui.switch('单句仅触发一次(文案)', value=config.get("key_mapping", "local_audio_single_sentence_trigger_once")).style(switch_internal_css).tooltip('一句话的数据,是否只让这句话触发一次本地音频映射,因为一句话中可能会有多个关键词,触发多次')
                             select_key_mapping_serial_trigger_type = ui.select(
                                 label='串口触发类型',
                                 options={'不启用': '不启用', '关键词': '关键词', '礼物': '礼物', '关键词+礼物': '关键词+礼物'},
                                 value=config.get("key_mapping", "serial_trigger_type")
-                            ).style("width:150px").tooltip('什么类型的数据会触发文案映射')
+                            ).style("width:120px").tooltip('什么类型的数据会触发文案映射')
                             switch_key_mapping_serial_single_sentence_trigger_once_enable = ui.switch('单句仅触发一次(串口)', value=config.get("key_mapping", "serial_single_sentence_trigger_once")).style(switch_internal_css).tooltip('一句话的数据,是否只让这句话触发一次文案映射,因为一句话中可能会有多个关键词,触发多次')
+                            select_key_mapping_img_path_trigger_type = ui.select(
+                                label='图片触发类型',
+                                options={'不启用': '不启用', '关键词': '关键词', '礼物': '礼物', '关键词+礼物': '关键词+礼物'},
+                                value=config.get("key_mapping", "img_path_trigger_type")
+                            ).style("width:120px").tooltip('什么类型的数据会触发文案映射')
+                            switch_key_mapping_img_path_single_sentence_trigger_once_enable = ui.switch('单句仅触发一次(图片显示)', value=config.get("key_mapping", "img_path_single_sentence_trigger_once")).style(switch_internal_css).tooltip('一句话的数据,是否只让这句话触发一次文案映射,因为一句话中可能会有多个关键词,触发多次')
                             
                         with ui.row():
                             input_key_mapping_index = ui.input(label='配置索引', value="", placeholder='配置组的排序号,就是说第一个组是1,第二个组是2,以此类推。请填写纯正整数').tooltip('配置组的排序号,就是说第一个组是1,第二个组是2,以此类推。请填写纯正整数')
@@ -4005,14 +4023,16 @@ def goto_func_page():
                         for index, key_mapping_config in enumerate(config.get("key_mapping", "config")):
                             with key_mapping_config_card.style(card_css):
                                 with ui.row():
-                                    key_mapping_config_var[str(8 * index)] = ui.textarea(label=f"关键词#{index + 1}", value=textarea_data_change(key_mapping_config["keywords"]), placeholder='此处输入触发的关键词,多个请以换行分隔').style("width:200px;").tooltip('此处输入触发的关键词,多个请以换行分隔')
-                                    key_mapping_config_var[str(8 * index + 1)] = ui.textarea(label=f"礼物#{index + 1}", value=textarea_data_change(key_mapping_config["gift"]), placeholder='此处输入触发的礼物名,多个请以换行分隔').style("width:200px;").tooltip('此处输入触发的礼物名,多个请以换行分隔')
-                                    key_mapping_config_var[str(8 * index + 2)] = ui.textarea(label=f"按键#{index + 1}", value=textarea_data_change(key_mapping_config["keys"]), placeholder='此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)').style("width:100px;").tooltip('此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)')
-                                    key_mapping_config_var[str(8 * index + 3)] = ui.input(label=f"相似度#{index + 1}", value=key_mapping_config["similarity"], placeholder='关键词与用户输入的相似度,默认1即100%').style("width:50px;").tooltip('关键词与用户输入的相似度,默认1即100%')
-                                    key_mapping_config_var[str(8 * index + 4)] = ui.textarea(label=f"文案#{index + 1}", value=textarea_data_change(key_mapping_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;").tooltip('此处输入触发后合成的文案内容,多个请以换行分隔')
-                                    key_mapping_config_var[str(8 * index + 5)] = ui.textarea(label=f"本地音频#{index + 1}", value=textarea_data_change(key_mapping_config["local_audio"]), placeholder='此处输入触发后播放的本地音频路径,多个请以换行分隔').style("width:300px;").tooltip('此处输入触发后播放的本地音频路径,多个请以换行分隔')
-                                    key_mapping_config_var[str(8 * index + 6)] = ui.input(label=f"串口名#{index + 1}", value=key_mapping_config["serial_name"], placeholder='例如:COM1').style("width:100px;").tooltip('串口页配置的串口名,例如:COM1')
-                                    key_mapping_config_var[str(8 * index + 7)] = ui.textarea(label=f"串口发送内容#{index + 1}", value=textarea_data_change(key_mapping_config["serial_send_data"]), placeholder='多个请以换行分隔,ASCII例如:open led\nHEX例如(2个字符的十六进制字符):313233').style("width:300px;").tooltip('此处输入发送到串口的数据内容,数据类型根据串口页设置决定,多个请以换行分隔')
+                                    num = 9
+                                    key_mapping_config_var[str(num * index)] = ui.textarea(label=f"关键词#{index + 1}", value=textarea_data_change(key_mapping_config["keywords"]), placeholder='此处输入触发的关键词,多个请以换行分隔').style("width:100px;").tooltip('此处输入触发的关键词,多个请以换行分隔')
+                                    key_mapping_config_var[str(num * index + 1)] = ui.textarea(label=f"礼物#{index + 1}", value=textarea_data_change(key_mapping_config["gift"]), placeholder='此处输入触发的礼物名,多个请以换行分隔').style("width:100px;").tooltip('此处输入触发的礼物名,多个请以换行分隔')
+                                    key_mapping_config_var[str(num * index + 2)] = ui.textarea(label=f"按键#{index + 1}", value=textarea_data_change(key_mapping_config["keys"]), placeholder='此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)').style("width:100px;").tooltip('此处输入你要映射的按键,多个按键请以换行分隔(按键名参考pyautogui规则)')
+                                    key_mapping_config_var[str(num * index + 3)] = ui.input(label=f"相似度#{index + 1}", value=key_mapping_config["similarity"], placeholder='关键词与用户输入的相似度,默认1即100%').style("width:50px;").tooltip('关键词与用户输入的相似度,默认1即100%')
+                                    key_mapping_config_var[str(num * index + 4)] = ui.textarea(label=f"文案#{index + 1}", value=textarea_data_change(key_mapping_config["copywriting"]), placeholder='此处输入触发后合成的文案内容,多个请以换行分隔').style("width:300px;").tooltip('此处输入触发后合成的文案内容,多个请以换行分隔')
+                                    key_mapping_config_var[str(num * index + 5)] = ui.textarea(label=f"本地音频#{index + 1}", value=textarea_data_change(key_mapping_config["local_audio"]), placeholder='此处输入触发后播放的本地音频路径,多个请以换行分隔').style("width:300px;").tooltip('此处输入触发后播放的本地音频路径,多个请以换行分隔')
+                                    key_mapping_config_var[str(num * index + 6)] = ui.input(label=f"串口名#{index + 1}", value=key_mapping_config["serial_name"], placeholder='例如:COM1').style("width:100px;").tooltip('串口页配置的串口名,例如:COM1')
+                                    key_mapping_config_var[str(num * index + 7)] = ui.textarea(label=f"串口发送内容#{index + 1}", value=textarea_data_change(key_mapping_config["serial_send_data"]), placeholder='多个请以换行分隔,ASCII例如:open led\nHEX例如(2个字符的十六进制字符):313233').style("width:300px;").tooltip('此处输入发送到串口的数据内容,数据类型根据串口页设置决定,多个请以换行分隔')
+                                    key_mapping_config_var[str(num * index + 8)] = ui.textarea(label=f"图片路径#{index + 1}", value=textarea_data_change(key_mapping_config["img_path"]), placeholder='多个请以换行分隔,支持绝对路径或相对路径,需要注意路径的斜杠哈').style("width:300px;").tooltip('此处输入图片路径,多个请以换行分隔。默认随机显示')
                                     
                                     # with ui.card().style(card_css):
                                     #     key_mapping_config_var[str(6 * index + 5)] = ui.textarea(label=f"串口号#{index + 1}", value=textarea_data_change(key_mapping_config["serial_name"]), placeholder='例如:COM1').style("width:100px;").tooltip('发送的串口名')