开发日志(三):只能生成菜品图片
一、做了什么我把自己的菜单解析项目继续往前推进了一步前端使用 Flutter后端使用 FastAPI菜单识别与菜品详情分析使用 Qwen 多模态 / 大语言模型新增能力点击菜品标签后在详情页为对应菜品生成图片也就是说现在项目已经不只是“识别菜单 翻译菜名 展示价格”了而是进一步实现了上传菜单图片后端调用大模型解析菜单内容前端展示结构化菜品列表点击某个菜品进入详情页后端继续调用大模型分析该菜的原料、过敏原、口味等信息再调用文生图模型生成该菜品的示意图并展示在详情页这个功能做完以后整个应用的完整度一下子提升了很多。二、项目效果1. 菜单解析结果页这一页展示的是从菜单图片中解析出的菜品列表包括原文菜名中文翻译中文描述价格标签2. 菜品详情页点击菜品后进入详情页展示菜品名称菜单原图价格菜品描述标签后端调用大模型分析得到的详细信息3. 调试过程中出现的错误页面这个页面其实很关键因为它记录了我今天最核心的一个踩坑点图片其实已经生成成功了但后端误以为失败了。后面我会详细讲这个问题是怎么定位和解决的。三、为什么要做“菜品图片生成”一开始我的项目只能做到菜单识别和结构化展示虽然功能已经比较完整但总觉得“视觉上还差一点”。尤其是详情页里用户点进去之后只有菜名菜单截图价格描述标签但是没有一张真正能代表菜品的图片页面显得比较“文字化”。所以我今天想做的目标很明确当用户点击菜品详情时自动为这个菜生成一张对应的示意图。这样详情页会更像一个真正的点餐应用而不只是一个菜单 OCR/解析工具。四、整体技术方案我现在的技术栈是Flutter前端页面与交互FastAPI后端 API 服务Qwen 多模态模型菜单图片识别Qwen3.5-Flash菜品详情分析Qwen-Image-2.0菜品图片生成整体流程如下用户上传菜单图片↓FastAPI 接收图片↓Qwen 多模态模型解析菜单↓返回结构化菜品列表给 Flutter↓用户点击某个菜品↓FastAPI 调用 Qwen3.5-Flash 分析该菜品详情↓FastAPI 调用 Qwen-Image-2.0 生成该菜品图片↓返回 base64 图片给 Flutter↓Flutter 详情页展示生成图五、菜单解析已经完成的部分在今天之前我已经实现了菜单图片上传与结构化解析。后端通过 Qwen 多模态模型把菜单图转成统一 JSON例如{success:true,message:解析成功,items:[{name_original:Salmon-Avocado salad with blueberries,name_zh:三文鱼牛油果沙拉配蓝莓,description:完美煎制的三文鱼搭配照烧酱、牛油果、西葫芦和蓝莓。,price:21,80€,tags:[沙拉,海鲜]}]}核心的菜单解析函数大致如下defparse_menu_image_with_qwen(image_bytes:bytes,content_type:str)-dict:image_base64base64.b64encode(image_bytes).decode(utf-8)image_data_urlfdata:{content_type};base64,{image_base64}prompt 你是一个菜单识别与翻译助手。 请识别这张菜单图片中的菜品信息并返回严格合法的 JSON。 不要输出解释不要输出 Markdown 代码块不要输出多余文字。 completionclient.chat.completions.create(modelqwen-vl-plus,messages[{role:user,content:[{type:text,text:prompt},{type:image_url,image_url:{url:image_data_url}}]}])result_textcompletion.choices[0].message.content.strip()datajson.loads(result_text)return{success:True,message:解析成功,items:data.get(items,[])}这一部分已经比较稳定所以今天的重点其实全部落在菜品图片生成上。六、先实现菜品详情分析在进入文生图之前我先给详情页补充了更丰富的信息比如原材料过敏原口味分布菜系类型烹饪方式营养信息饮食限制比如牛肉鞑靼就能分析出生牛肉酸豆红洋葱酸黄瓜第戎芥末伍斯特酱以及它属于法餐 / 西餐生食搅拌高蛋白、富含铁质核心代码如下defget_dish_details_with_qwen(dish_name:str,original_name:strNone)-dict:promptf 你是一个专业的美食分析助手。请为菜品 {dish_name}{ (原文: original_name)iforiginal_nameelse}提供详细的分析信息。 请返回严格合法的 JSON 格式不要输出解释或其他多余文字。 completionclient.chat.completions.create(modelqwen3.5-flash,messages[{role:user,content:prompt}])result_textcompletion.choices[0].message.content.strip()datajson.loads(result_text)return{success:True,message:获取详细信息成功,details:data}这一步做完以后详情页的文字信息已经相当丰富了。七、正式开始做菜品图片生成目标我希望用户点击一个菜品后后端能够根据菜名描述原料菜系烹饪方式自动拼出 prompt再交给文生图模型生成图片。比如对于这道菜凯撒沙拉配烤鸡肉 / 烤虾后端会拼成类似这样的提示词Create a realistic,appetizing food photo of 凯撒沙拉配烤鸡肉/烤虾.Dish description:罗马生菜鸡肉或虾番茄鸡蛋帕尔马干酪面包丁凯撒酱。 Professional food photography,realistic plating,natural lighting,restaurant-style presentation,high detail,clean background八、今天踩到的第一个坑旧接口直接 403最开始我用的是旧的文生图调用方式大致是这种urlhttps://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesispayload{model:wanx-v1,input:{prompt:full_prompt}}结果一调用就报错403Client Error:Forbidden当时我的第一反应是API Key 没权限模型没开通地域不对请求头写错了后来排查发现不是权限没开而是我用的是旧的文生图接口思路而我在阿里云百炼业务空间里开通的是Qwen-Image-2.0系列模型。也就是说模型开通了但接口格式和调用方式不匹配。九、第二个坑改成新接口后又报“异步调用不支持”查文档后我把图片生成切到了新的接口https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation并尝试走异步任务流给请求头加了X-DashScope-Async:enable本来以为这下应该行了结果又收到一个 403AccessDenied: current user api does not support asynchronous calls这说明当前 API Key 不支持异步方式调用必须用同步调用。解决方案去掉 X-DashScope-Async不再轮询 task_id改成同步接口结果解析十、第三个坑图片其实生成成功了但我代码还以为失败这个坑是今天最关键、也最有意思的一个。我把异步头去掉以后接口终于不再 403 了。但是前端页面上还是提示生成图片失败、无法获取任务 ID。但仔细看后端返回内容发现里面其实已经有图片链接了{output:{choices:[{finish_reason:stop,message:{content:[{image:https://dashscope-xxxx.png}],role:assistant}}]}}问题根源我原来的判断逻辑是task_idresult.get(output,{}).get(task_id)ifnottask_id:return失败同步接口根本不会返回 task_id所以图片明明生成成功了后端却误判为失败。十一、最终解决方案最终我把图片生成改成了真正的同步处理流程直接请求 Qwen-Image-2.0从 output.choices[0].message.content[0].image 里取图片 URL下载图片转成 base64返回给 Flutter最终版核心函数如下defgenerate_dish_image_with_qwen(dish_name:str,description:str,ingredients:listNone,cuisine_type:str,cooking_method:str)-dict:try:ifnotapi_key:return{success:False,message:Qwen API key not configured,image_base64:None}prompt_parts[fCreate a realistic, appetizing food photo of{dish_name}]ifdescription:prompt_parts.append(fDish description:{description})ifingredients:ingredients_str, .join([str(x)forxiningredients[:6]ifx])ifingredients_str:prompt_parts.append(fMain ingredients:{ingredients_str})ifcuisine_type:prompt_parts.append(fCuisine style:{cuisine_type})ifcooking_method:prompt_parts.append(fCooking method:{cooking_method})prompt_parts.append(Professional food photography, realistic plating, natural lighting, restaurant-style presentation, high detail, clean background)full_prompt. .join(prompt_parts)urlhttps://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generationheaders{Authorization:fBearer{api_key},Content-Type:application/json}payload{model:qwen-image-2.0,input:{messages:[{role:user,content:[{text:full_prompt}]}]},parameters:{size:1024*1024,n:1,prompt_extend:True}}responserequests.post(url,jsonpayload,headersheaders,timeout180)response.raise_for_status()resultresponse.json()image_urlNonechoicesresult.get(output,{}).get(choices,[])ifchoices:messagechoices[0].get(message,{})contentmessage.get(content,[])ifcontentandisinstance(content,list):image_urlcontent[0].get(image)ifnotimage_url:return{success:False,message:f未找到图片URL,image_base64:None}image_responserequests.get(image_url,timeout180)image_response.raise_for_status()image_base64_database64.b64encode(image_response.content).decode(utf-8)return{success:True,message:图片生成成功,image_base64:image_base64_data}exceptExceptionase:return{success:False,message:fFailed:{str(e)},image_base64:None}