room-measurement.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /**
  2. * 毛坯房量尺 — 5轮聚焦提示词模块
  3. *
  4. * 从 analyze-photos-v4.mjs 移植,API 调用委托给 vision-client.mjs。
  5. *
  6. * 使用示例:
  7. * import { processPhoto, PASS_CONFIGS } from './prompts/room-measurement.mjs';
  8. * const result = await processPhoto('/path/to/photo.jpg', 'img-001', 'room-a.jpg');
  9. */
  10. import fs from 'fs';
  11. import path from 'path';
  12. import { callVisionAPI } from '../vision-client.mjs';
  13. // ============================================================
  14. // 5轮聚焦提示词
  15. // ============================================================
  16. export const PASS1_SYSTEM = `你是一位建筑空间分析专家。你的任务是精确分析毛坯房照片的**空间结构**。
  17. ## 规则
  18. 1. **透视类型**:判断一点透视/两点透视/三点透视。
  19. - 一点透视:正面墙正对镜头,水平线汇聚到画面中心
  20. - 两点透视:墙角在画面中心附近,两侧墙面分别向左右消失
  21. - 三点透视:仰拍/俯拍导致垂直线也汇聚
  22. - 特别注意:如果看到两个墙面以夹角呈现(墙角在画面中心附近),必须报告 twoPoint
  23. 2. **墙面多边形**:每面可见墙标注**精确的4个角点**(四边形),沿建筑实际边缘。
  24. - surfaceType: facing(正面)/leftWall(左墙)/rightWall(右墙)
  25. - 每条边放3个等分测量点(measurePoints)
  26. 3. **天花/地面区域**:各标注4个角点的多边形
  27. 4. **阴阳角**:标注位置(x,y)
  28. 5. **忽略**所有小物件、家具、装饰、门窗、吊顶细节——这些会在后续分析中处理
  29. ## 输出格式(严格JSON,无markdown代码块)
  30. {
  31. "pass": 1,
  32. "perspective": {"type": "onePoint|twoPoint|threePoint", "description": "透视说明", "vanishingPoints": [{"x": 50, "y": 40}]},
  33. "surfaces": {
  34. "walls": [
  35. {"id": "w1", "label": "正面主墙", "surfaceType": "facing",
  36. "polygon": [{"x":20,"y":25},{"x":75,"y":25},{"x":75,"y":82},{"x":20,"y":80}],
  37. "measureLines": [
  38. {"label":"顶边3点","type":"horizontal","edge":"top","startPoint":{"x":20,"y":25},"endPoint":{"x":75,"y":25},"measurePoints":[{"x":20,"y":25},{"x":47.5,"y":25},{"x":75,"y":25}]},
  39. {"label":"底边3点","type":"horizontal","edge":"bottom","startPoint":{"x":20,"y":80},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":20,"y":80},{"x":47.5,"y":81},{"x":75,"y":82}]},
  40. {"label":"左边3点","type":"vertical","edge":"left","startPoint":{"x":20,"y":25},"endPoint":{"x":20,"y":80},"measurePoints":[{"x":20,"y":25},{"x":20,"y":52.5},{"x":20,"y":80}]},
  41. {"label":"右边3点","type":"vertical","edge":"right","startPoint":{"x":75,"y":25},"endPoint":{"x":75,"y":82},"measurePoints":[{"x":75,"y":25},{"x":75,"y":53.5},{"x":75,"y":82}]}
  42. ]}
  43. ],
  44. "floorRegion": {"polygon": [{"x":0,"y":80},{"x":100,"y":80},{"x":100,"y":100},{"x":0,"y":100}], "label": "可见地面"},
  45. "ceilingRegion": {"polygon": [{"x":0,"y":0},{"x":100,"y":0},{"x":100,"y":20},{"x":0,"y":20}], "label": "可见天花"}
  46. },
  47. "corners": [
  48. {"id":"c1","type":"internal","label":"左阴角","position":{"x":20,"y":55}},
  49. {"id":"c2","type":"internal","label":"右阴角","position":{"x":75,"y":55}}
  50. ]
  51. }`;
  52. export const PASS1_USER = `请分析这张照片的**空间结构**:
  53. 1. 判断透视类型(一点/两点/三点),找消失点
  54. 2. 标注每面可见墙的4角多边形,区分facing/leftWall/rightWall
  55. 3. 标注天花/地面区域
  56. 4. 标注阴阳角位置
  57. 只输出JSON,不包含其他内容:`;
  58. export const PASS2_SYSTEM = `你是一位吊顶与天花结构分析专家。你的任务是精确分析照片中的**天花板特征**。
  59. ## 规则
  60. 1. **只标注天花板上的结构特征**,忽略墙面、地面、门窗、障碍物
  61. 2. **关键:每个特征必须用4个角点的简单四边形标注**。即使实际形状不规则,也只能用4点近似。禁止使用5点或更多点。
  62. 3. 特征类型:
  63. - cornice: 石膏线/阴角线(天花与墙面交界处的装饰线条)
  64. - trayStep: 吊顶叠级/双眼皮(不同高度的吊顶分界线)
  65. - beam: 梁/下返结构
  66. - bulkhead: 窗帘盒/设备带(局部下返区域)
  67. - soffit: 管道包封/检修口
  68. 4. polygon的4个点按顺时针方向标注
  69. ## 输出格式(严格JSON,无markdown代码块)
  70. {
  71. "pass": 2,
  72. "ceilingFeatures": [
  73. {"id":"cf1","type":"cornice","label":"石膏阴角线",
  74. "polygon": [{"x":0,"y":8},{"x":100,"y":8},{"x":100,"y":12},{"x":0,"y":12}]},
  75. {"id":"cf2","type":"trayStep","label":"第一层叠级线",
  76. "polygon": [{"x":20,"y":22},{"x":80,"y":22},{"x":80,"y":26},{"x":20,"y":26}]}
  77. ]
  78. }
  79. 如果没有可见的天花特征,返回空数组:{"pass":2,"ceilingFeatures":[]}`;
  80. export const PASS2_USER = `请分析这张照片的**天花板特征**:
  81. 1. 石膏线/阴角线(cornice)
  82. 2. 吊顶叠级/双眼皮(trayStep)
  83. 3. 梁/下返结构(beam)
  84. 4. 窗帘盒/设备带(bulkhead)
  85. 记住:每个特征只能用4个角点标注!简单四边形!
  86. 只输出JSON:`;
  87. export const PASS3_SYSTEM = `你是一位门窗洞口测量专家。你的任务是精确分析照片中的**所有门洞和窗洞**。
  88. ## 规则
  89. 1. **只标注门洞和窗洞**,忽略其他所有元素(墙壁、天花、障碍物等)
  90. 2. 每个洞口标注**双层框架**:
  91. - outerPolygon: 洞口在墙面上的外轮廓(4个角点,即墙面上的实际开口边缘)
  92. - innerPolygon: 门扇/窗扇/玻璃区域的内轮廓(4个角点)
  93. - frameThickness: 门套/窗套线宽度(百分比),如无套线则为0
  94. 3. 测量线沿外框放置:上中下宽度3点 + 左中右高度3点
  95. 4. 如果无可见洞口,返回空数组
  96. ## 输出格式(严格JSON,无markdown代码块)
  97. {
  98. "pass": 3,
  99. "openings": [
  100. {"id":"d1","type":"door","label":"入户门",
  101. "frame": {
  102. "outerPolygon": [{"x":35,"y":20},{"x":55,"y":18},{"x":55,"y":80},{"x":35,"y":82}],
  103. "innerPolygon": [{"x":37,"y":22},{"x":53,"y":20},{"x":53,"y":78},{"x":37,"y":80}],
  104. "frameThickness": 2.0
  105. },
  106. "measureLines": [
  107. {"label":"门洞上口宽","type":"horizontal","startPoint":{"x":35,"y":20},"endPoint":{"x":55,"y":18},"measurePoints":[{"x":35,"y":20},{"x":45,"y":19},{"x":55,"y":18}]},
  108. {"label":"门洞左口高","type":"vertical","startPoint":{"x":35,"y":20},"endPoint":{"x":35,"y":82},"measurePoints":[{"x":35,"y":20},{"x":35,"y":51},{"x":35,"y":82}]}
  109. ]}
  110. ]
  111. }`;
  112. export const PASS3_USER = `请分析这张照片的**所有门洞和窗洞**:
  113. 1. 标注外层框架(outerPolygon,墙上开口的精确边缘)
  114. 2. 标注内层框架(innerPolygon,门扇/玻璃边缘)
  115. 3. 标注门套/窗套厚度(frameThickness)
  116. 4. 放置测量点
  117. 只输出JSON:`;
  118. export const PASS4_SYSTEM = `你是一位全屋定制障碍物检测专家。你的任务是精确标注照片中**所有可见障碍物**的包围盒。
  119. ## 核心原则
  120. 每个包围盒(boundingBox)告诉测量人员"需要测量这个矩形区域的实际尺寸"。你必须非常精确——贴合物体的真实可见边缘。
  121. ## 障碍物类型
  122. - outlet(插座): 86型约2%×2%, 118型约3%×2%
  123. - switch(开关): 同插座
  124. - electricBox(电箱): 箱体外框,通常5-15%
  125. - vent(风口): 格栅外框在吊顶/墙上
  126. - pipe(管道): 管道与墙/地接触范围
  127. - baseboard(踢脚线): 墙底水平条带
  128. - doorFrame(门套线): 门套在墙上的宽度条带
  129. - windowFrame(窗套线): 窗套在墙上的范围
  130. - gasMeter(燃气表): 表箱外框
  131. - floorDrain(地漏): 地面位置
  132. - downlight(筒灯): 天花位置
  133. ## ⚠️ 踢脚线高度规则(非常重要!)
  134. - 踢脚线(baseboard)的高度必须在 2%-5% 之间
  135. - 这是踢脚线条带**本身**的高度,不是从踢脚线到墙顶的距离
  136. - 正面墙(facing)踢脚线:沿着墙底的水平窄条,height = 2-4%
  137. - 侧墙(leftWall/rightWall)踢脚线:height = 2-5%(不要被透视缩短误导!)
  138. - **如果标注的height > 10%,一定是错误的——请重新检查!**那是整面墙的高度,不是踢脚线
  139. - 侧墙的踢脚线:看墙底部那条水平的细线/条带,标注那条条带的高度
  140. ## 包围盒格式
  141. boundingBox: { x, y, width, height } — 全部百分比
  142. - x, y: 包围盒左上角相对于图片的百分比位置
  143. - width, height: 包围盒的宽高百分比
  144. ## 输出格式(严格JSON,无markdown代码块)
  145. {
  146. "pass": 4,
  147. "obstacles": [
  148. {"id":"obs1","type":"outlet","label":"五孔插座(86型)","boundingBox":{"x":42,"y":56,"width":2.5,"height":3.2}},
  149. {"id":"obs2","type":"baseboard","label":"木质踢脚线","boundingBox":{"x":20,"y":80,"width":55,"height":3}},
  150. {"id":"obs3","type":"vent","label":"空调出风口","boundingBox":{"x":8,"y":10,"width":14,"height":4}}
  151. ]
  152. }`;
  153. export const PASS4_USER = `请分析这张照片的**所有障碍物**:
  154. 1. 插座、开关、电箱
  155. 2. 风口(空调、新风、排风)
  156. 3. 管道
  157. 4. 踢脚线(⚠️ height必须2-5%,不能是整面墙高度!)
  158. 5. 门套线、窗套线
  159. 6. 燃气表、地漏
  160. 7. 筒灯、射灯
  161. 每个障碍物用精确的boundingBox{x,y,width,height}标注。
  162. 只输出JSON:`;
  163. export const PASS5_SYSTEM = `你是一位全屋定制测量专家。你有4份针对同一房间的分析数据,分别来自不同专家的独立观察。请将它们合并为一份完整的测量分析报告。
  164. ## 你的任务
  165. 1. 阅读4份数据,理解空间结构
  166. 2. 写出 sceneDescription(完整的场景描述,2-3句话)
  167. 3. 判断 roomType(卧室/客厅/厨房/卫生间/阳台/走廊/储物间/其他)
  168. 4. 生成 measurementPlan(测量计划),将所有元素关联到测量步骤
  169. 5. 评估 photoQuality(是否广角、畸变程度、是否需要补拍)
  170. 6. 列出 issues(如有遮挡、光线不足等问题)
  171. ## 测量计划规则
  172. - 每面墙至少一个步骤(3点宽+3点高)
  173. - 每个门洞/窗洞一个步骤
  174. - 每组同类障碍物可以合并为一个步骤(如"测量所有插座位置")
  175. - 步骤按重要性排序:required > recommended > optional
  176. - elementIds必须引用实际存在的ID(来自输入数据)
  177. - 工具:激光测距仪(长距离)、卷尺(小尺寸)、水平仪(垂直度)
  178. ## 输出格式(严格JSON,无markdown代码块)
  179. {
  180. "pass": 5,
  181. "sceneDescription": "完整的场景描述...",
  182. "roomType": "卧室",
  183. "measurementPlan": [
  184. {"step":1,"action":"测量正面主墙顶中底3点宽度与左中右3点高度","target":"w1","tool":"激光测距仪","priority":"required","elementIds":["w1"]}
  185. ],
  186. "photoQuality": {"isWideAngle":true,"distortionLevel":"low","recommendReshoot":false,"reshootAdvice":""},
  187. "issues": []
  188. }`;
  189. export const PASS5_USER_TEMPLATE = `以下是一个房间的4份独立分析数据。请将它们合并:
  190. === 空间结构 ===
  191. __PASS1__
  192. === 吊顶特征 ===
  193. __PASS2__
  194. === 门窗洞口 ===
  195. __PASS3__
  196. === 障碍物 ===
  197. __PASS4__
  198. 请生成完整的测量分析报告。只输出JSON:`;
  199. // ============================================================
  200. // 轮次配置(供 callMultiPass 使用)
  201. // ============================================================
  202. export const PASS_CONFIGS = [
  203. { name: 'spatial', systemPrompt: PASS1_SYSTEM, userPrompt: PASS1_USER, maxTokens: 2000 },
  204. { name: 'ceiling', systemPrompt: PASS2_SYSTEM, userPrompt: PASS2_USER, maxTokens: 1000 },
  205. { name: 'openings', systemPrompt: PASS3_SYSTEM, userPrompt: PASS3_USER, maxTokens: 2500 },
  206. { name: 'obstacles', systemPrompt: PASS4_SYSTEM, userPrompt: PASS4_USER, maxTokens: 1500 },
  207. ];
  208. // ============================================================
  209. // 合并函数
  210. // ============================================================
  211. export function mergeResults(photoId, fileName, passResults) {
  212. const p1 = passResults[0]?.parsed || {};
  213. const p2 = passResults[1]?.parsed || {};
  214. const p3 = passResults[2]?.parsed || {};
  215. const p4 = passResults[3]?.parsed || {};
  216. const p5 = passResults[4]?.parsed || {};
  217. const merged = {
  218. version: 'v4-multipass',
  219. photoId,
  220. fileName,
  221. analyzedAt: new Date().toISOString(),
  222. passes: passResults.map((p, i) => ({
  223. pass: i + 1,
  224. name: p.name || `pass${i + 1}`,
  225. status: p.error ? 'error' : 'ok',
  226. error: p.error || null,
  227. usage: p.usage || null,
  228. })),
  229. parsed: {
  230. sceneDescription: p5.sceneDescription || '',
  231. roomType: p5.roomType || '',
  232. perspective: p1.perspective || { type: 'onePoint', description: '', vanishingPoints: [] },
  233. surfaces: p1.surfaces || { walls: [], floorRegion: null, ceilingRegion: null },
  234. openings: p3.openings || [],
  235. ceilingFeatures: p2.ceilingFeatures || [],
  236. corners: p1.corners || [],
  237. obstacles: p4.obstacles || [],
  238. measurementPlan: p5.measurementPlan || [],
  239. issues: p5.issues || [],
  240. photoQuality: p5.photoQuality || { isWideAngle: false, distortionLevel: 'unknown', recommendReshoot: false, reshootAdvice: '' },
  241. },
  242. };
  243. // 质量验证:踢脚线高度检查
  244. const suspiciousBaseboards = (merged.parsed.obstacles || []).filter(
  245. o => o.type === 'baseboard' && o.boundingBox?.height > 10
  246. );
  247. if (suspiciousBaseboards.length > 0) {
  248. console.log(` ⚠ 发现 ${suspiciousBaseboards.length} 个异常踢脚线高度>10%:`);
  249. suspiciousBaseboards.forEach(o => {
  250. console.log(` ${o.id}: height=${o.boundingBox.height}% (预计2-5%)`);
  251. });
  252. }
  253. // 质量验证:吊顶特征顶点数检查
  254. const complexCeilings = (merged.parsed.ceilingFeatures || []).filter(
  255. cf => cf.polygon && cf.polygon.length > 4
  256. );
  257. if (complexCeilings.length > 0) {
  258. console.log(` ⚠ 发现 ${complexCeilings.length} 个吊顶特征顶点>4:`);
  259. complexCeilings.forEach(cf => {
  260. console.log(` ${cf.id}: ${cf.polygon.length}点 (期望4点)`);
  261. });
  262. }
  263. return merged;
  264. }
  265. // ============================================================
  266. // 主流程:处理单张照片
  267. // ============================================================
  268. /**
  269. * 对单张毛坯房照片执行 5-pass 分析
  270. *
  271. * @param {string} imagePath 图片路径
  272. * @param {string} photoId 照片 ID(用于缓存目录命名)
  273. * @param {string} fileName 原始文件名
  274. * @param {Object} [opts]
  275. * @param {string} [opts.cacheDir] 缓存目录,默认 './output/v4/<photoId>'
  276. * @param {string} [opts.model] 模型名
  277. * @returns {Promise<Object>} 合并后的分析结果
  278. */
  279. export async function processPhoto(imagePath, photoId, fileName, opts = {}) {
  280. const cacheDir = opts.cacheDir || path.resolve('./output/v4', photoId);
  281. // Pass 1-4: 视觉分析
  282. const passResults = [];
  283. for (const cfg of PASS_CONFIGS) {
  284. const passNum = cfg.name === 'spatial' ? 1 : cfg.name === 'ceiling' ? 2 : cfg.name === 'openings' ? 3 : 4;
  285. const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
  286. if (fs.existsSync(cacheFile)) {
  287. console.log(` Pass ${passNum} (${cfg.name}): 已有缓存,跳过`);
  288. passResults.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
  289. continue;
  290. }
  291. console.log(` Pass ${passNum} (${cfg.name}, ${cfg.maxTokens}t)...`);
  292. try {
  293. const result = await callVisionAPI({
  294. imagePath,
  295. systemPrompt: cfg.systemPrompt,
  296. userPrompt: cfg.userPrompt,
  297. maxTokens: cfg.maxTokens,
  298. model: opts.model,
  299. });
  300. const entry = { pass: passNum, name: cfg.name, ...result };
  301. if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
  302. fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
  303. passResults.push(entry);
  304. console.log(` ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
  305. } catch (e) {
  306. console.log(` ✗ ${e.message}`);
  307. const entry = { pass: passNum, name: cfg.name, error: e.message, parsed: null, usage: null };
  308. if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
  309. fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
  310. passResults.push(entry);
  311. }
  312. await new Promise(r => setTimeout(r, 1500));
  313. }
  314. // Pass 5: 文本合并
  315. const pass5File = path.join(cacheDir, 'pass5.json');
  316. if (fs.existsSync(pass5File)) {
  317. console.log(' Pass 5 (merge): 已有缓存,跳过');
  318. passResults.push(JSON.parse(fs.readFileSync(pass5File, 'utf-8')));
  319. } else {
  320. console.log(' Pass 5 (merge, 2000t)...');
  321. const p1Json = JSON.stringify(passResults[0]?.parsed || {}, null, 2);
  322. const p2Json = JSON.stringify(passResults[1]?.parsed || {}, null, 2);
  323. const p3Json = JSON.stringify(passResults[2]?.parsed || {}, null, 2);
  324. const p4Json = JSON.stringify(passResults[3]?.parsed || {}, null, 2);
  325. const mergePrompt = PASS5_USER_TEMPLATE
  326. .replace('__PASS1__', p1Json)
  327. .replace('__PASS2__', p2Json)
  328. .replace('__PASS3__', p3Json)
  329. .replace('__PASS4__', p4Json);
  330. try {
  331. const result = await callVisionAPI({
  332. systemPrompt: PASS5_SYSTEM,
  333. userPrompt: mergePrompt,
  334. maxTokens: 2000,
  335. model: opts.model,
  336. });
  337. const entry = { pass: 5, name: 'merge', ...result };
  338. fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
  339. passResults.push(entry);
  340. console.log(` ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
  341. } catch (e) {
  342. console.log(` ✗ ${e.message}`);
  343. const entry = { pass: 5, name: 'merge', error: e.message, parsed: null, usage: null };
  344. fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
  345. passResults.push(entry);
  346. }
  347. }
  348. return mergeResults(photoId, fileName, passResults);
  349. }