| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- /**
- * 毛坯房量尺 — 5轮聚焦提示词模块
- *
- * 从 analyze-photos-v4.mjs 移植,API 调用委托给 vision-client.mjs。
- *
- * 使用示例:
- * import { processPhoto, PASS_CONFIGS } from './prompts/room-measurement.mjs';
- * const result = await processPhoto('/path/to/photo.jpg', 'img-001', 'room-a.jpg');
- */
- import fs from 'fs';
- import path from 'path';
- import { callVisionAPI } from '../vision-client.mjs';
- // ============================================================
- // 5轮聚焦提示词
- // ============================================================
- export const PASS1_SYSTEM = `你是一位建筑空间分析专家。你的任务是精确分析毛坯房照片的**空间结构**。
- ## 规则
- 1. **透视类型**:判断一点透视/两点透视/三点透视。
- - 一点透视:正面墙正对镜头,水平线汇聚到画面中心
- - 两点透视:墙角在画面中心附近,两侧墙面分别向左右消失
- - 三点透视:仰拍/俯拍导致垂直线也汇聚
- - 特别注意:如果看到两个墙面以夹角呈现(墙角在画面中心附近),必须报告 twoPoint
- 2. **墙面多边形**:每面可见墙标注**精确的4个角点**(四边形),沿建筑实际边缘。
- - surfaceType: facing(正面)/leftWall(左墙)/rightWall(右墙)
- - 每条边放3个等分测量点(measurePoints)
- 3. **天花/地面区域**:各标注4个角点的多边形
- 4. **阴阳角**:标注位置(x,y)
- 5. **忽略**所有小物件、家具、装饰、门窗、吊顶细节——这些会在后续分析中处理
- ## 输出格式(严格JSON,无markdown代码块)
- {
- "pass": 1,
- "perspective": {"type": "onePoint|twoPoint|threePoint", "description": "透视说明", "vanishingPoints": [{"x": 50, "y": 40}]},
- "surfaces": {
- "walls": [
- {"id": "w1", "label": "正面主墙", "surfaceType": "facing",
- "polygon": [{"x":20,"y":25},{"x":75,"y":25},{"x":75,"y":82},{"x":20,"y":80}],
- "measureLines": [
- {"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}]},
- {"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}]},
- {"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}]},
- {"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}]}
- ]}
- ],
- "floorRegion": {"polygon": [{"x":0,"y":80},{"x":100,"y":80},{"x":100,"y":100},{"x":0,"y":100}], "label": "可见地面"},
- "ceilingRegion": {"polygon": [{"x":0,"y":0},{"x":100,"y":0},{"x":100,"y":20},{"x":0,"y":20}], "label": "可见天花"}
- },
- "corners": [
- {"id":"c1","type":"internal","label":"左阴角","position":{"x":20,"y":55}},
- {"id":"c2","type":"internal","label":"右阴角","position":{"x":75,"y":55}}
- ]
- }`;
- export const PASS1_USER = `请分析这张照片的**空间结构**:
- 1. 判断透视类型(一点/两点/三点),找消失点
- 2. 标注每面可见墙的4角多边形,区分facing/leftWall/rightWall
- 3. 标注天花/地面区域
- 4. 标注阴阳角位置
- 只输出JSON,不包含其他内容:`;
- export const PASS2_SYSTEM = `你是一位吊顶与天花结构分析专家。你的任务是精确分析照片中的**天花板特征**。
- ## 规则
- 1. **只标注天花板上的结构特征**,忽略墙面、地面、门窗、障碍物
- 2. **关键:每个特征必须用4个角点的简单四边形标注**。即使实际形状不规则,也只能用4点近似。禁止使用5点或更多点。
- 3. 特征类型:
- - cornice: 石膏线/阴角线(天花与墙面交界处的装饰线条)
- - trayStep: 吊顶叠级/双眼皮(不同高度的吊顶分界线)
- - beam: 梁/下返结构
- - bulkhead: 窗帘盒/设备带(局部下返区域)
- - soffit: 管道包封/检修口
- 4. polygon的4个点按顺时针方向标注
- ## 输出格式(严格JSON,无markdown代码块)
- {
- "pass": 2,
- "ceilingFeatures": [
- {"id":"cf1","type":"cornice","label":"石膏阴角线",
- "polygon": [{"x":0,"y":8},{"x":100,"y":8},{"x":100,"y":12},{"x":0,"y":12}]},
- {"id":"cf2","type":"trayStep","label":"第一层叠级线",
- "polygon": [{"x":20,"y":22},{"x":80,"y":22},{"x":80,"y":26},{"x":20,"y":26}]}
- ]
- }
- 如果没有可见的天花特征,返回空数组:{"pass":2,"ceilingFeatures":[]}`;
- export const PASS2_USER = `请分析这张照片的**天花板特征**:
- 1. 石膏线/阴角线(cornice)
- 2. 吊顶叠级/双眼皮(trayStep)
- 3. 梁/下返结构(beam)
- 4. 窗帘盒/设备带(bulkhead)
- 记住:每个特征只能用4个角点标注!简单四边形!
- 只输出JSON:`;
- export const PASS3_SYSTEM = `你是一位门窗洞口测量专家。你的任务是精确分析照片中的**所有门洞和窗洞**。
- ## 规则
- 1. **只标注门洞和窗洞**,忽略其他所有元素(墙壁、天花、障碍物等)
- 2. 每个洞口标注**双层框架**:
- - outerPolygon: 洞口在墙面上的外轮廓(4个角点,即墙面上的实际开口边缘)
- - innerPolygon: 门扇/窗扇/玻璃区域的内轮廓(4个角点)
- - frameThickness: 门套/窗套线宽度(百分比),如无套线则为0
- 3. 测量线沿外框放置:上中下宽度3点 + 左中右高度3点
- 4. 如果无可见洞口,返回空数组
- ## 输出格式(严格JSON,无markdown代码块)
- {
- "pass": 3,
- "openings": [
- {"id":"d1","type":"door","label":"入户门",
- "frame": {
- "outerPolygon": [{"x":35,"y":20},{"x":55,"y":18},{"x":55,"y":80},{"x":35,"y":82}],
- "innerPolygon": [{"x":37,"y":22},{"x":53,"y":20},{"x":53,"y":78},{"x":37,"y":80}],
- "frameThickness": 2.0
- },
- "measureLines": [
- {"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}]},
- {"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}]}
- ]}
- ]
- }`;
- export const PASS3_USER = `请分析这张照片的**所有门洞和窗洞**:
- 1. 标注外层框架(outerPolygon,墙上开口的精确边缘)
- 2. 标注内层框架(innerPolygon,门扇/玻璃边缘)
- 3. 标注门套/窗套厚度(frameThickness)
- 4. 放置测量点
- 只输出JSON:`;
- export const PASS4_SYSTEM = `你是一位全屋定制障碍物检测专家。你的任务是精确标注照片中**所有可见障碍物**的包围盒。
- ## 核心原则
- 每个包围盒(boundingBox)告诉测量人员"需要测量这个矩形区域的实际尺寸"。你必须非常精确——贴合物体的真实可见边缘。
- ## 障碍物类型
- - outlet(插座): 86型约2%×2%, 118型约3%×2%
- - switch(开关): 同插座
- - electricBox(电箱): 箱体外框,通常5-15%
- - vent(风口): 格栅外框在吊顶/墙上
- - pipe(管道): 管道与墙/地接触范围
- - baseboard(踢脚线): 墙底水平条带
- - doorFrame(门套线): 门套在墙上的宽度条带
- - windowFrame(窗套线): 窗套在墙上的范围
- - gasMeter(燃气表): 表箱外框
- - floorDrain(地漏): 地面位置
- - downlight(筒灯): 天花位置
- ## ⚠️ 踢脚线高度规则(非常重要!)
- - 踢脚线(baseboard)的高度必须在 2%-5% 之间
- - 这是踢脚线条带**本身**的高度,不是从踢脚线到墙顶的距离
- - 正面墙(facing)踢脚线:沿着墙底的水平窄条,height = 2-4%
- - 侧墙(leftWall/rightWall)踢脚线:height = 2-5%(不要被透视缩短误导!)
- - **如果标注的height > 10%,一定是错误的——请重新检查!**那是整面墙的高度,不是踢脚线
- - 侧墙的踢脚线:看墙底部那条水平的细线/条带,标注那条条带的高度
- ## 包围盒格式
- boundingBox: { x, y, width, height } — 全部百分比
- - x, y: 包围盒左上角相对于图片的百分比位置
- - width, height: 包围盒的宽高百分比
- ## 输出格式(严格JSON,无markdown代码块)
- {
- "pass": 4,
- "obstacles": [
- {"id":"obs1","type":"outlet","label":"五孔插座(86型)","boundingBox":{"x":42,"y":56,"width":2.5,"height":3.2}},
- {"id":"obs2","type":"baseboard","label":"木质踢脚线","boundingBox":{"x":20,"y":80,"width":55,"height":3}},
- {"id":"obs3","type":"vent","label":"空调出风口","boundingBox":{"x":8,"y":10,"width":14,"height":4}}
- ]
- }`;
- export const PASS4_USER = `请分析这张照片的**所有障碍物**:
- 1. 插座、开关、电箱
- 2. 风口(空调、新风、排风)
- 3. 管道
- 4. 踢脚线(⚠️ height必须2-5%,不能是整面墙高度!)
- 5. 门套线、窗套线
- 6. 燃气表、地漏
- 7. 筒灯、射灯
- 每个障碍物用精确的boundingBox{x,y,width,height}标注。
- 只输出JSON:`;
- export const PASS5_SYSTEM = `你是一位全屋定制测量专家。你有4份针对同一房间的分析数据,分别来自不同专家的独立观察。请将它们合并为一份完整的测量分析报告。
- ## 你的任务
- 1. 阅读4份数据,理解空间结构
- 2. 写出 sceneDescription(完整的场景描述,2-3句话)
- 3. 判断 roomType(卧室/客厅/厨房/卫生间/阳台/走廊/储物间/其他)
- 4. 生成 measurementPlan(测量计划),将所有元素关联到测量步骤
- 5. 评估 photoQuality(是否广角、畸变程度、是否需要补拍)
- 6. 列出 issues(如有遮挡、光线不足等问题)
- ## 测量计划规则
- - 每面墙至少一个步骤(3点宽+3点高)
- - 每个门洞/窗洞一个步骤
- - 每组同类障碍物可以合并为一个步骤(如"测量所有插座位置")
- - 步骤按重要性排序:required > recommended > optional
- - elementIds必须引用实际存在的ID(来自输入数据)
- - 工具:激光测距仪(长距离)、卷尺(小尺寸)、水平仪(垂直度)
- ## 输出格式(严格JSON,无markdown代码块)
- {
- "pass": 5,
- "sceneDescription": "完整的场景描述...",
- "roomType": "卧室",
- "measurementPlan": [
- {"step":1,"action":"测量正面主墙顶中底3点宽度与左中右3点高度","target":"w1","tool":"激光测距仪","priority":"required","elementIds":["w1"]}
- ],
- "photoQuality": {"isWideAngle":true,"distortionLevel":"low","recommendReshoot":false,"reshootAdvice":""},
- "issues": []
- }`;
- export const PASS5_USER_TEMPLATE = `以下是一个房间的4份独立分析数据。请将它们合并:
- === 空间结构 ===
- __PASS1__
- === 吊顶特征 ===
- __PASS2__
- === 门窗洞口 ===
- __PASS3__
- === 障碍物 ===
- __PASS4__
- 请生成完整的测量分析报告。只输出JSON:`;
- // ============================================================
- // 轮次配置(供 callMultiPass 使用)
- // ============================================================
- export const PASS_CONFIGS = [
- { name: 'spatial', systemPrompt: PASS1_SYSTEM, userPrompt: PASS1_USER, maxTokens: 2000 },
- { name: 'ceiling', systemPrompt: PASS2_SYSTEM, userPrompt: PASS2_USER, maxTokens: 1000 },
- { name: 'openings', systemPrompt: PASS3_SYSTEM, userPrompt: PASS3_USER, maxTokens: 2500 },
- { name: 'obstacles', systemPrompt: PASS4_SYSTEM, userPrompt: PASS4_USER, maxTokens: 1500 },
- ];
- // ============================================================
- // 合并函数
- // ============================================================
- export function mergeResults(photoId, fileName, passResults) {
- const p1 = passResults[0]?.parsed || {};
- const p2 = passResults[1]?.parsed || {};
- const p3 = passResults[2]?.parsed || {};
- const p4 = passResults[3]?.parsed || {};
- const p5 = passResults[4]?.parsed || {};
- const merged = {
- version: 'v4-multipass',
- photoId,
- fileName,
- analyzedAt: new Date().toISOString(),
- passes: passResults.map((p, i) => ({
- pass: i + 1,
- name: p.name || `pass${i + 1}`,
- status: p.error ? 'error' : 'ok',
- error: p.error || null,
- usage: p.usage || null,
- })),
- parsed: {
- sceneDescription: p5.sceneDescription || '',
- roomType: p5.roomType || '',
- perspective: p1.perspective || { type: 'onePoint', description: '', vanishingPoints: [] },
- surfaces: p1.surfaces || { walls: [], floorRegion: null, ceilingRegion: null },
- openings: p3.openings || [],
- ceilingFeatures: p2.ceilingFeatures || [],
- corners: p1.corners || [],
- obstacles: p4.obstacles || [],
- measurementPlan: p5.measurementPlan || [],
- issues: p5.issues || [],
- photoQuality: p5.photoQuality || { isWideAngle: false, distortionLevel: 'unknown', recommendReshoot: false, reshootAdvice: '' },
- },
- };
- // 质量验证:踢脚线高度检查
- const suspiciousBaseboards = (merged.parsed.obstacles || []).filter(
- o => o.type === 'baseboard' && o.boundingBox?.height > 10
- );
- if (suspiciousBaseboards.length > 0) {
- console.log(` ⚠ 发现 ${suspiciousBaseboards.length} 个异常踢脚线高度>10%:`);
- suspiciousBaseboards.forEach(o => {
- console.log(` ${o.id}: height=${o.boundingBox.height}% (预计2-5%)`);
- });
- }
- // 质量验证:吊顶特征顶点数检查
- const complexCeilings = (merged.parsed.ceilingFeatures || []).filter(
- cf => cf.polygon && cf.polygon.length > 4
- );
- if (complexCeilings.length > 0) {
- console.log(` ⚠ 发现 ${complexCeilings.length} 个吊顶特征顶点>4:`);
- complexCeilings.forEach(cf => {
- console.log(` ${cf.id}: ${cf.polygon.length}点 (期望4点)`);
- });
- }
- return merged;
- }
- // ============================================================
- // 主流程:处理单张照片
- // ============================================================
- /**
- * 对单张毛坯房照片执行 5-pass 分析
- *
- * @param {string} imagePath 图片路径
- * @param {string} photoId 照片 ID(用于缓存目录命名)
- * @param {string} fileName 原始文件名
- * @param {Object} [opts]
- * @param {string} [opts.cacheDir] 缓存目录,默认 './output/v4/<photoId>'
- * @param {string} [opts.model] 模型名
- * @returns {Promise<Object>} 合并后的分析结果
- */
- export async function processPhoto(imagePath, photoId, fileName, opts = {}) {
- const cacheDir = opts.cacheDir || path.resolve('./output/v4', photoId);
- // Pass 1-4: 视觉分析
- const passResults = [];
- for (const cfg of PASS_CONFIGS) {
- const passNum = cfg.name === 'spatial' ? 1 : cfg.name === 'ceiling' ? 2 : cfg.name === 'openings' ? 3 : 4;
- const cacheFile = path.join(cacheDir, `pass${passNum}.json`);
- if (fs.existsSync(cacheFile)) {
- console.log(` Pass ${passNum} (${cfg.name}): 已有缓存,跳过`);
- passResults.push(JSON.parse(fs.readFileSync(cacheFile, 'utf-8')));
- continue;
- }
- console.log(` Pass ${passNum} (${cfg.name}, ${cfg.maxTokens}t)...`);
- try {
- const result = await callVisionAPI({
- imagePath,
- systemPrompt: cfg.systemPrompt,
- userPrompt: cfg.userPrompt,
- maxTokens: cfg.maxTokens,
- model: opts.model,
- });
- const entry = { pass: passNum, name: cfg.name, ...result };
- if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
- fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
- passResults.push(entry);
- console.log(` ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
- } catch (e) {
- console.log(` ✗ ${e.message}`);
- const entry = { pass: passNum, name: cfg.name, error: e.message, parsed: null, usage: null };
- if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
- fs.writeFileSync(cacheFile, JSON.stringify(entry, null, 2));
- passResults.push(entry);
- }
- await new Promise(r => setTimeout(r, 1500));
- }
- // Pass 5: 文本合并
- const pass5File = path.join(cacheDir, 'pass5.json');
- if (fs.existsSync(pass5File)) {
- console.log(' Pass 5 (merge): 已有缓存,跳过');
- passResults.push(JSON.parse(fs.readFileSync(pass5File, 'utf-8')));
- } else {
- console.log(' Pass 5 (merge, 2000t)...');
- const p1Json = JSON.stringify(passResults[0]?.parsed || {}, null, 2);
- const p2Json = JSON.stringify(passResults[1]?.parsed || {}, null, 2);
- const p3Json = JSON.stringify(passResults[2]?.parsed || {}, null, 2);
- const p4Json = JSON.stringify(passResults[3]?.parsed || {}, null, 2);
- const mergePrompt = PASS5_USER_TEMPLATE
- .replace('__PASS1__', p1Json)
- .replace('__PASS2__', p2Json)
- .replace('__PASS3__', p3Json)
- .replace('__PASS4__', p4Json);
- try {
- const result = await callVisionAPI({
- systemPrompt: PASS5_SYSTEM,
- userPrompt: mergePrompt,
- maxTokens: 2000,
- model: opts.model,
- });
- const entry = { pass: 5, name: 'merge', ...result };
- fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
- passResults.push(entry);
- console.log(` ${result.error ? '✗ ' + result.error : '✓ OK'} | tokens:${result.usage?.total_tokens || '?'}`);
- } catch (e) {
- console.log(` ✗ ${e.message}`);
- const entry = { pass: 5, name: 'merge', error: e.message, parsed: null, usage: null };
- fs.writeFileSync(pass5File, JSON.stringify(entry, null, 2));
- passResults.push(entry);
- }
- }
- return mergeResults(photoId, fileName, passResults);
- }
|