full_server.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. """
  2. Full Floorplan Pipeline - Single Endpoint Server.
  3. Call once, everything is handled: RGB → initial_mask → refine_mask → JSON
  4. Endpoints:
  5. POST /process — run the complete pipeline for a folder
  6. GET /health — health check
  7. """
  8. import os
  9. import gc
  10. import json
  11. import subprocess
  12. import sys
  13. import cv2
  14. import numpy as np
  15. import shutil
  16. from fastapi import FastAPI, Form, UploadFile, File
  17. from pydantic import BaseModel, Field
  18. app = FastAPI(title="Floorplan Pipeline")
  19. # ========================================================================
  20. # 1. extract_initial_mask (BiRefNet)
  21. # ========================================================================
  22. def red_edge_generate(img_path, save_path):
  23. img = cv2.imread(img_path)
  24. img_2 = np.zeros_like(img)
  25. mask = (img[:, :, 0] == 0) * (img[:, :, 1] == 0) * (img[:, :, 2] == 0)
  26. img_2[~mask] = (255, 255, 255)
  27. edges = cv2.Canny(img_2, 50, 150)
  28. kernel = np.ones((3, 3), np.uint8)
  29. edges = cv2.dilate(edges, kernel, 1)
  30. mask = edges[:, :, None] / 255.0
  31. masks = np.concatenate([mask, mask, mask], axis=-1)
  32. img1 = (masks * (0.0, 0.0, 255.0)).clip(0, 255)
  33. alpha = 0.9
  34. img = img1 * alpha + img * (1 - masks * alpha)
  35. cv2.imwrite(save_path, img)
  36. def generate_initial_mask(rgb_path, output_path, model_path, img_name):
  37. """Subprocess: red-edge + BiRefNet → initial_mask"""
  38. result = subprocess.run([
  39. sys.executable, "-c", f'''
  40. import os, gc, torch, onnxruntime, cv2, numpy as np
  41. from PIL import Image
  42. def red_edge_generate(img_path, save_path):
  43. img = cv2.imread(img_path)
  44. img_2 = np.zeros_like(img)
  45. mask = (img[:, :, 0] == 0) * (img[:, :, 1] == 0) * (img[:, :, 2] == 0)
  46. img_2[~mask] = (255, 255, 255)
  47. edges = cv2.Canny(img_2, 50, 150)
  48. kernel = np.ones((3, 3), np.uint8)
  49. edges = cv2.dilate(edges, kernel, 1)
  50. mask = edges[:, :, None] / 255.0
  51. masks = np.concatenate([mask, mask, mask], axis=-1)
  52. img1 = (masks * (0.0, 0.0, 255.0)).clip(0, 255)
  53. alpha = 0.9
  54. img = img1 * alpha + img * (1 - masks * alpha)
  55. cv2.imwrite(save_path, img)
  56. def predict_birefnet_onnx(image_path, onnx_session, mask_dir, input_size=(1024, 1024)):
  57. orig_img = Image.open(image_path).convert("RGB")
  58. w_orig, h_orig = orig_img.size
  59. img_resized = orig_img.resize(input_size, resample=Image.BILINEAR)
  60. img_np = np.array(img_resized).astype(np.float32) / 255.0
  61. mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
  62. std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
  63. img_np = (img_np - mean) / std
  64. img_np = img_np.transpose(2, 0, 1)[np.newaxis, :]
  65. img_np = np.ascontiguousarray(img_np)
  66. input_name = onnx_session.get_inputs()[0].name
  67. outputs = onnx_session.run(None, {{input_name: img_np}})
  68. raw_preds = outputs[-1]
  69. pred_mask = 1 / (1 + np.exp(-raw_preds))
  70. pred_mask = pred_mask.squeeze()
  71. mask_resized = cv2.resize(pred_mask, (w_orig, h_orig), interpolation=cv2.INTER_LINEAR)
  72. mask_8bit = (mask_resized * 255).astype(np.uint8)
  73. kernel = np.ones((3, 3), np.uint8)
  74. mask_eroded = cv2.erode(mask_8bit, kernel, iterations=1)
  75. if not os.path.exists(mask_dir):
  76. os.makedirs(mask_dir)
  77. save_path = os.path.join(mask_dir, os.path.basename(image_path))
  78. cv2.imwrite(save_path, mask_eroded)
  79. red_edge_generate("{rgb_path}", "{rgb_path}")
  80. session = onnxruntime.InferenceSession("{model_path}", providers=[("CUDAExecutionProvider", {{"device_id": 0}})])
  81. temp_dir = "{os.path.dirname(output_path)}/_temp_masks"
  82. os.makedirs(temp_dir, exist_ok=True)
  83. predict_birefnet_onnx("{rgb_path}", session, temp_dir)
  84. temp_path = os.path.join(temp_dir, os.path.basename("{img_name}"))
  85. import shutil
  86. shutil.move(temp_path, "{output_path}")
  87. try:
  88. os.rmdir(temp_dir)
  89. except:
  90. pass
  91. del session
  92. torch.cuda.empty_cache()
  93. torch.cuda.synchronize()
  94. gc.collect()
  95. gc.collect()
  96. ''',
  97. ], capture_output=True, text=True)
  98. if result.returncode != 0:
  99. raise RuntimeError(f"initial_mask 生成失败: {result.stderr}")
  100. # ========================================================================
  101. # 2. inference_refine_mask (FLUX.2-klein)
  102. # ========================================================================
  103. def generate_refine_mask(initial_mask_path, output_path, flux_model_path):
  104. """Subprocess: FLUX.2-klein initial_mask → refine_mask"""
  105. result = subprocess.run([
  106. sys.executable, "-c", f'''
  107. import gc, torch
  108. import os as _os
  109. _os.environ["CUDA_VISIBLE_DEVICES"] = "1"
  110. from diffusers import Flux2KleinPipeline
  111. from diffusers.utils import load_image
  112. pipe = Flux2KleinPipeline.from_pretrained("{flux_model_path}", torch_dtype=torch.bfloat16)
  113. pipe = pipe.to("cuda")
  114. generator = torch.Generator(device="cuda").manual_seed(0)
  115. image = load_image("{initial_mask_path}")
  116. base_width, base_height = image.size
  117. target_width = (base_width // 8) * 8
  118. target_height = (base_height // 8) * 8
  119. prompt = """
  120. (best quality, 4k), architectural floor plan mask, instance segmentation,
  121. do not add extra blocks,
  122. distinct separate white blocks, clear black gaps between rooms,
  123. separated connected components, clean sharp edges, top-down view,
  124. binary mask style, white rooms on black background, no touching blocks,
  125. The image should be positioned exactly as it was in the original image; do not shift it.
  126. logical room separation
  127. """
  128. result_img = pipe(
  129. image=image, prompt=prompt,
  130. height=target_height, width=target_width,
  131. guidance_scale=4.0, num_inference_steps=4,
  132. generator=generator
  133. ).images[0]
  134. result_img.resize((base_width, base_height)).save("{output_path}")
  135. del pipe, generator
  136. torch.cuda.empty_cache()
  137. torch.cuda.synchronize()
  138. gc.collect()
  139. gc.collect()
  140. ''',
  141. ], capture_output=True, text=True, timeout=300)
  142. if result.returncode != 0:
  143. raise RuntimeError(f"refine_mask 生成失败: {result.stderr}")
  144. # ========================================================================
  145. # 3. pipeline (core processing)
  146. # ========================================================================
  147. _model_cache = {}
  148. def _load_yolo(p):
  149. if p not in _model_cache:
  150. from ultralytics import YOLO
  151. _model_cache[p] = YOLO(p)
  152. return _model_cache[p]
  153. def _to_gray(mask):
  154. return mask if len(mask.shape) == 2 else cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
  155. def remove_pure_red(img):
  156. if img is None:
  157. return
  158. red = (img[:, :, 0] == 0) & (img[:, :, 1] == 0) & (img[:, :, 2] == 255)
  159. img[red] = [0, 0, 0]
  160. return img
  161. def remove_edge_regions_image(img):
  162. result = img.copy()
  163. img_2 = np.zeros_like(result)
  164. mask = (result[:, :, 0] == 0) & (result[:, :, 1] == 0) & (result[:, :, 2] == 0)
  165. img_2[~mask] = (255, 255, 255)
  166. edges = cv2.Canny(img_2, 50, 150)
  167. kernel = np.ones((9, 9), np.uint8)
  168. edges = cv2.dilate(edges, kernel, 1)
  169. result[edges > 0] = (0, 0, 0)
  170. return remove_pure_red(result)
  171. def extract_gaps_from_mask(mask):
  172. _, binary = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
  173. kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 25))
  174. stitched = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
  175. gaps = cv2.subtract(stitched, binary)
  176. rk = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
  177. gaps_d = cv2.dilate(gaps, rk, 1)
  178. return gaps, cv2.add(mask, gaps_d)
  179. def extract_mask_region_from_arrays(rgb, ori, full):
  180. _, bo = cv2.threshold(ori, 127, 255, cv2.THRESH_BINARY)
  181. _, bf = cv2.threshold(full, 127, 255, cv2.THRESH_BINARY)
  182. r1 = cv2.bitwise_and(rgb, rgb, mask=bo)
  183. r2 = cv2.bitwise_and(rgb, rgb, mask=bf)
  184. return cv2.subtract(r2, r1)
  185. def _expand_rect(x, y, w, h, ep, wmax, hmax):
  186. if ep <= 0:
  187. return int(x), int(y), int(w), int(h)
  188. return (max(0, int(x) - ep), max(0, int(y) - ep),
  189. max(1, min(wmax, int(x) + int(w) + ep) - max(0, int(x) - ep)),
  190. max(1, min(hmax, int(y) + int(h) + ep) - max(0, int(y) - ep)))
  191. def merge_gap_fillers_from_arrays(m1, m2, image_path="", dilation_kernel_size=5,
  192. center_threshold=50, expand_pixel=10, min_rect_short_side=30):
  193. m1 = _to_gray(m1)
  194. m2 = _to_gray(m2)
  195. if m1.shape != m2.shape:
  196. m2 = cv2.resize(m2, (m1.shape[1], m1.shape[0]))
  197. _, m1b = cv2.threshold(m1, 127, 255, cv2.THRESH_BINARY)
  198. _, m2b = cv2.threshold(m2, 0, 255, cv2.THRESH_BINARY)
  199. num1, bl = cv2.connectedComponents(m1b)
  200. num2, fl, _, _ = cv2.connectedComponentsWithStats(m2b)
  201. result = cv2.cvtColor(m1b, cv2.COLOR_GRAY2BGR)
  202. bridge = []
  203. for i in range(1, num2):
  204. sfm = (fl == i).astype(np.uint8) * 255
  205. k = np.ones((dilation_kernel_size, dilation_kernel_size), np.uint8)
  206. df = cv2.dilate(sfm, k, 1)
  207. tl = np.unique(bl[df > 0])
  208. nb = sorted(int(n) for n in tl if n > 0)
  209. if len(nb) == 2:
  210. pts = np.column_stack(np.where(sfm > 0))
  211. if len(pts) > 0:
  212. cy, cx = np.mean(pts, axis=0)
  213. bridge.append({'id': i, 'cx': cx, 'cy': cy,
  214. 'bp': tuple(int(n - 1) for n in nb), 'mask': sfm})
  215. groups = []
  216. for frag in bridge:
  217. assigned = False
  218. for g in groups:
  219. if g[0]['bp'] != frag['bp']:
  220. continue
  221. for ex in g:
  222. if abs(frag['cx'] - ex['cx']) < center_threshold or abs(frag['cy'] - ex['cy']) < center_threshold:
  223. g.append(frag)
  224. assigned = True
  225. break
  226. if assigned:
  227. break
  228. if not assigned:
  229. groups.append([frag])
  230. areas, rid = [], 0
  231. h, w = m1.shape[:2]
  232. for group in groups:
  233. plist, frects = [], []
  234. for frag in group:
  235. pts = np.column_stack(np.where(frag['mask'] > 0))
  236. if pts.size > 0:
  237. px = pts[:, ::-1]
  238. plist.append(px)
  239. rx, ry, rw, rh = cv2.boundingRect(px)
  240. frects.append((int(rx), int(ry), int(rw), int(rh), frag))
  241. if len(plist) < 1:
  242. continue
  243. ap = np.vstack(plist)
  244. if len(ap) < 3:
  245. continue
  246. rx, ry, rw, rh = cv2.boundingRect(ap)
  247. mss = min(rw, rh)
  248. if len(group) > 1 and mss > min_rect_short_side:
  249. for fx, fy, fw, fh, frag in frects:
  250. if min(fw, fh) <= min_rect_short_side:
  251. fx, fy, fw, fh = _expand_rect(fx, fy, fw, fh, expand_pixel, w, h)
  252. cv2.rectangle(result, (fx, fy), (fx + fw, fy + fh), (0, 255, 0), -1)
  253. if min(fw, fh) >= 25:
  254. areas.append({'id': rid, 'x': fx, 'y': fy, 'w': fw, 'h': fh,
  255. 'block_pair': [int(n) for n in frag['bp']], 'label': 'door'})
  256. rid += 1
  257. continue
  258. if mss <= min_rect_short_side:
  259. x, y, ww, hh = _expand_rect(rx, ry, rw, rh, expand_pixel, w, h)
  260. cv2.rectangle(result, (x, y), (x + ww, y + hh), (0, 255, 0), -1)
  261. if min(ww, hh) >= 25:
  262. areas.append({'id': rid, 'x': x, 'y': y, 'w': ww, 'h': hh,
  263. 'block_pair': [int(n) for n in group[0]['bp']], 'label': 'door'})
  264. rid += 1
  265. return {
  266. 'image_path': str(image_path),
  267. 'image_size': {'width': int(m1.shape[1]), 'height': int(m1.shape[0])},
  268. 'connect_area': areas
  269. }, result, {
  270. 'mask1_blocks': num1 - 1, 'mask2_fragments': num2 - 1,
  271. 'bridge_fragments': len(bridge), 'group_count': len(groups),
  272. 'connect_area_count': len(areas)
  273. }
  274. def build_block_data(rgb_img, block_mask, model_path="room_cls.pt"):
  275. model = _load_yolo(model_path)
  276. blocks = _to_gray(block_mask)
  277. if rgb_img.shape[:2] != blocks.shape[:2]:
  278. rgb_img = cv2.resize(rgb_img, (blocks.shape[1], blocks.shape[0]))
  279. _, bb = cv2.threshold(blocks, 127, 255, cv2.THRESH_BINARY)
  280. num_blocks, bl = cv2.connectedComponents(bb, connectivity=8)
  281. blist = []
  282. for b in range(1, num_blocks):
  283. ms = (bl == b).astype(np.uint8)
  284. cnt, _ = cv2.findContours(ms, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  285. pts, cx, cy = [], 0, 0
  286. if cnt:
  287. lc = max(cnt, key=cv2.contourArea)
  288. simp = cv2.approxPolyDP(lc, 2.0, True)
  289. for pt in simp:
  290. pts.extend([int(pt[0][0]), int(pt[0][1])])
  291. M = cv2.moments(simp)
  292. if M['m00']:
  293. cx, cy = int(M['m10'] / M['m00']), int(M['m01'] / M['m00'])
  294. else:
  295. cx, cy = int(np.mean(simp[:, 0, 0])), int(np.mean(simp[:, 0, 1]))
  296. x, y, w, h = cv2.boundingRect(ms)
  297. if not cnt:
  298. cx, cy = int(x + w / 2), int(y + h / 2)
  299. pad = 20
  300. y1, y2 = max(0, y - pad), min(rgb_img.shape[0], y + h + pad)
  301. x1, x2 = max(0, x - pad), min(rgb_img.shape[1], x + w + pad)
  302. roi = rgb_img[y1:y2, x1:x2].copy()
  303. roi[ms[y1:y2, x1:x2] == 0] = [0, 0, 0]
  304. label, conf = "other_room", 0.0
  305. if roi.shape[0] > 10 and roi.shape[1] > 10:
  306. res = model(roi, verbose=False)[0]
  307. if hasattr(res, 'probs') and res.probs is not None:
  308. tc = float(res.probs.top1conf.cpu().numpy())
  309. if tc >= 0.15:
  310. label, conf = model.names[int(res.probs.top1)], tc
  311. blist.append({"id": b - 1, "points": pts, "label": label, "center": [cx, cy]})
  312. return blist
  313. def detect_furniture(rgb_img, model_path='furniture_detect.onnx'):
  314. model = _load_yolo(model_path)
  315. res = model(rgb_img, conf=0.25, verbose=False)[0]
  316. allowed = {'sofa', 'chair', 'desk', 'bed', 'window'}
  317. fl = []
  318. if len(res.boxes) > 0:
  319. bx = res.boxes.xyxy.cpu().numpy()
  320. bc = res.boxes.cls.cpu().numpy()
  321. for i in range(len(bx)):
  322. b1, y1, b2, y2 = [int(v) for v in bx[i]]
  323. lb = model.names[int(bc[i])]
  324. if lb not in allowed:
  325. continue
  326. fl.append({'id': len(fl), 'label': lb,
  327. 'center': [(b1 + b2) // 2, (y1 + y2) // 2],
  328. 'points': {'x1': b1, 'y1': y1, 'x2': b2, 'y2': y1,
  329. 'x3': b2, 'y3': y2, 'x4': b1, 'y4': y2}})
  330. return fl
  331. # ── Refinement / normalization / merge (inline from pipeline.py) ────────
  332. def refine_blocks_in_data(data):
  333. blocks = data.get("block", [])
  334. total = 0
  335. for idx, block in enumerate(blocks):
  336. pd2 = block.get("points", [])
  337. if not pd2:
  338. continue
  339. if isinstance(pd2[0], list) and len(pd2[0]) == 4:
  340. segs = [[float(v) for v in s] for s in pd2]
  341. else:
  342. pts = [[float(pd2[i]), float(pd2[i + 1])] for i in range(0, len(pd2), 2) if i + 1 < len(pd2)]
  343. segs = [[p[0], p[1], pts[(j + 1) % len(pts)][0], pts[(j + 1) % len(pts)][1]] for j, p in enumerate(pts)]
  344. refined = refine_single_block_segments(segs) or segs
  345. segs_int = [[int(round(s[0])), int(round(s[1])), int(round(s[2])), int(round(s[3]))] for s in refined]
  346. block["points"] = segs_int
  347. block["refined"] = True
  348. block["format"] = "segments"
  349. block["segment_count"] = len(segs_int)
  350. total += len(segs_int)
  351. data["format_version"] = "segments_v1"
  352. data["total_segments"] = total
  353. def refine_single_block_segments(segments):
  354. if not segments:
  355. return []
  356. g = orthogonalize_and_move_nodes([segments], 15)[0]
  357. r = apply_user_refinement(g, 30, 30)
  358. r = merge_collinear(r, 2)
  359. r = merge_parallel(r, 12)
  360. return r
  361. def to_key(p):
  362. return (round(float(p[0]), 1), round(float(p[1]), 1))
  363. def is_orthogonal(seg, t=1e-1):
  364. return abs(seg[2] - seg[0]) < t or abs(seg[3] - seg[1]) < t
  365. def orthogonalize_and_move_nodes(sgs, ath):
  366. out = []
  367. for grp in sgs:
  368. np2 = {}
  369. def gsn(p):
  370. pk = to_key(p)
  371. for ep, o in np2.items():
  372. if np.linalg.norm(np.array(pk) - np.array(ep)) < 2.5:
  373. return o
  374. nn = np.array(p, dtype=np.float32)
  375. np2[pk] = nn
  376. return nn
  377. gs = [(gsn(s[:2]), gsn(s[2:])) for s in grp]
  378. for _ in range(3):
  379. for p1, p2 in gs:
  380. dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1])
  381. a = np.degrees(np.arctan2(dy, dx))
  382. if a < ath or a > (180 - ath):
  383. ay = (p1[1] + p2[1]) / 2
  384. p1[1] = p2[1] = ay
  385. elif abs(a - 90) < ath:
  386. ax = (p1[0] + p2[0]) / 2
  387. p1[0] = p2[0] = ax
  388. out.append([[p1[0], p1[1], p2[0], p2[1]] for p1, p2 in gs])
  389. return out
  390. def apply_user_refinement(grp, lt=30, at=30):
  391. if len(grp) < 2:
  392. return grp
  393. atr = np.radians(at)
  394. si = next((i for i, s in enumerate(grp) if is_orthogonal(s)), -1)
  395. if si == -1:
  396. return grp
  397. wl = [list(s) for s in (grp[si:] + grp[:si])]
  398. ref, i = [], 0
  399. while i < len(wl):
  400. cs = wl[i]
  401. ref.append(cs)
  402. ni = i + 1
  403. if ni >= len(wl):
  404. break
  405. if not is_orthogonal(wl[ni]):
  406. flp, tgl, gs = False, 0, []
  407. pi = ni
  408. while pi < len(wl):
  409. if is_orthogonal(wl[pi]):
  410. flp = True
  411. break
  412. s = wl[pi]
  413. tgl += np.sqrt((s[2] - s[0]) ** 2 + (s[3] - s[1]) ** 2)
  414. gs.append(s)
  415. pi += 1
  416. if flp:
  417. lp = wl[pi]
  418. if tgl < lt:
  419. pe, ls = [cs[2], cs[3]], [lp[0], lp[1]]
  420. ih1, ih2 = abs(cs[3] - cs[1]) < 1e-1, abs(lp[3] - lp[1]) < 1e-1
  421. pm = [pe[0], ls[1]] if ih1 == ih2 else [ls[0], pe[1]] if ih1 else [pe[0], ls[1]]
  422. lp[0], lp[1] = pm[0], pm[1]
  423. ref.append([pe[0], pe[1], lp[0], lp[1]])
  424. i = pi
  425. else:
  426. gs[0][0], gs[0][1] = cs[2], cs[3]
  427. mg, ts = [], list(gs[0])
  428. for k in range(1, len(gs)):
  429. ns = gs[k]
  430. v1, v2 = (ts[2] - ts[0], ts[3] - ts[1]), (ns[2] - ns[0], ns[3] - ns[1])
  431. m1, m2 = np.sqrt(v1[0]**2 + v1[1]**2), np.sqrt(v2[0]**2 + v2[1]**2)
  432. if m1 > 1e-6 and m2 > 1e-6:
  433. ct = abs(v1[0]*v2[0] + v1[1]*v2[1]) / (m1 * m2)
  434. if np.arccos(max(-1, min(1, ct))) < atr:
  435. ts[2], ts[3] = ns[2], ns[3]
  436. continue
  437. mg.append(ts)
  438. ts = list(ns)
  439. ts[0], ts[1] = mg[-1][2], mg[-1][3]
  440. mg.append(ts)
  441. mg[-1][2], mg[-1][3] = lp[0], lp[1]
  442. ref.extend(mg)
  443. i = pi
  444. else:
  445. ref.extend(wl[ni:])
  446. break
  447. else:
  448. i += 1
  449. return ref
  450. def merge_collinear(og, dt=0.5):
  451. if len(og) < 2:
  452. return og
  453. mg, cs = [], list(og[0])
  454. for i in range(1, len(og)):
  455. ns = og[i]
  456. ih = abs(cs[1] - cs[3]) < dt and abs(ns[1] - ns[3]) < dt and abs(cs[3] - ns[1]) < dt
  457. iv = abs(cs[0] - cs[2]) < dt and abs(ns[0] - ns[2]) < dt and abs(cs[2] - ns[0]) < dt
  458. if ih or iv:
  459. cs[2], cs[3] = ns[2], ns[3]
  460. else:
  461. mg.append(cs)
  462. cs = list(ns)
  463. mg.append(cs)
  464. return mg
  465. def merge_parallel(grp, dt=15.0):
  466. if not grp:
  467. return grp
  468. sg = [list(s) for s in grp]
  469. it = 0
  470. while True:
  471. mir, mi = False, set()
  472. for i in range(len(sg)):
  473. if i in mi:
  474. continue
  475. for j in range(i + 1, len(sg)):
  476. if j in mi:
  477. continue
  478. s1, s2 = sg[i], sg[j]
  479. ih1, ih2 = abs(s1[1] - s1[3]) < 1e-1, abs(s2[1] - s2[3]) < 1e-1
  480. iv1, iv2 = abs(s1[0] - s1[2]) < 1e-1, abs(s2[0] - s2[2]) < 1e-1
  481. if ih1 and ih2:
  482. d = abs(s1[1] - s2[1])
  483. ol = min(max(s1[0], s1[2]), max(s2[0], s2[2])) - max(min(s1[0], s1[2]), min(s2[0], s2[2]))
  484. if d < dt and ol > 0:
  485. np2 = (s1[1] + s2[1]) / 2
  486. for s in sg:
  487. if abs(s[1] - s1[1]) < 1e-1 or abs(s[1] - s2[1]) < 1e-1:
  488. s[1] = np2
  489. if abs(s[3] - s1[1]) < 1e-1 or abs(s[3] - s2[1]) < 1e-1:
  490. s[3] = np2
  491. s1[0], s1[2] = min(s1[0], s1[2], s2[0], s2[2]), max(s1[0], s1[2], s2[0], s2[2])
  492. s1[1], s1[3] = min(s1[1], s1[3], s2[1], s2[3]), max(s1[1], s1[3], s2[1], s2[3])
  493. mi.add(j)
  494. mir = True
  495. break
  496. elif iv1 and iv2:
  497. d = abs(s1[0] - s2[0])
  498. ol = min(max(s1[1], s1[3]), max(s2[1], s2[3])) - max(min(s1[1], s1[3]), min(s2[1], s2[3]))
  499. if d < dt and ol > 0:
  500. np2 = (s1[0] + s2[0]) / 2
  501. for s in sg:
  502. if abs(s[0] - s1[0]) < 1e-1 or abs(s[0] - s2[0]) < 1e-1:
  503. s[0] = np2
  504. if abs(s[2] - s1[0]) < 1e-1 or abs(s[2] - s2[0]) < 1e-1:
  505. s[2] = np2
  506. s1[0], s1[2] = min(s1[0], s1[2], s2[0], s2[2]), max(s1[0], s1[2], s2[0], s2[2])
  507. s1[1], s1[3] = min(s1[1], s1[3], s2[1], s2[3]), max(s1[1], s1[3], s2[1], s2[3])
  508. mi.add(j)
  509. mir = True
  510. break
  511. if mir:
  512. break
  513. if mi:
  514. sg = [s for idx, s in enumerate(sg) if idx not in mi]
  515. if not mir:
  516. break
  517. it += 1
  518. if it > 100:
  519. break
  520. return sg
  521. def normalize_segment(seg, angle=10):
  522. x1, y1, x2, y2 = seg
  523. dx, dy = x2 - x1, y2 - y1
  524. if dx == 0 and dy == 0:
  525. return seg
  526. theta = abs(np.degrees(np.arctan2(abs(dy), abs(dx))))
  527. if theta <= angle:
  528. my = round((y1 + y2) / 2)
  529. return [x1, my, x2, my]
  530. if theta >= 90 - angle:
  531. mx = round((x1 + x2) / 2)
  532. return [mx, y1, mx, y2]
  533. return seg
  534. def normalize_all(data, angle=10):
  535. for b in data.get('block', []):
  536. b['points'] = [normalize_segment(s, angle) for s in b['points']]
  537. def segment_orientation(seg):
  538. return 'H' if seg[1] == seg[3] else ('V' if seg[0] == seg[2] else None)
  539. def segments_overlap(sa, sb, ori):
  540. if ori == 'H':
  541. a1, a2, b1, b2 = min(sa[0], sa[2]), max(sa[0], sa[2]), min(sb[0], sb[2]), max(sb[0], sb[2])
  542. else:
  543. a1, a2, b1, b2 = min(sa[1], sa[3]), max(sa[1], sa[3]), min(sb[1], sb[3]), max(sb[1], sb[3])
  544. return a1 <= b2 and b1 <= a2
  545. def cluster_and_merge(segs, ori, thresh):
  546. idxs = [i for i, s in enumerate(segs) if segment_orientation(s) == ori]
  547. if not idxs:
  548. return False
  549. parent = {i: i for i in idxs}
  550. def find(x):
  551. while parent[x] != x:
  552. parent[x] = parent[parent[x]]
  553. x = parent[x]
  554. return x
  555. def union(x, y):
  556. parent[find(x)] = find(y)
  557. for ii in range(len(idxs)):
  558. for jj in range(ii + 1, len(idxs)):
  559. i, j = idxs[ii], idxs[jj]
  560. si, sj = segs[i], segs[j]
  561. ci = si[1] if ori == 'H' else si[0]
  562. cj = sj[1] if ori == 'H' else sj[0]
  563. if abs(ci - cj) <= thresh and segments_overlap(si, sj, ori):
  564. union(i, j)
  565. from collections import defaultdict
  566. clusters = defaultdict(list)
  567. for i in idxs:
  568. clusters[find(i)].append(i)
  569. changed = False
  570. for members in clusters.values():
  571. if len(members) < 2:
  572. continue
  573. coords = [segs[i][1] if ori == 'H' else segs[i][0] for i in members]
  574. ov = list(set(coords))
  575. if len(ov) == 1:
  576. continue
  577. nv = round(sum(coords) / len(coords))
  578. os2 = set(ov)
  579. for k, s in enumerate(segs):
  580. so = segment_orientation(s)
  581. if ori == 'H':
  582. if so == 'H' and s[1] in os2:
  583. segs[k][1] = segs[k][3] = nv
  584. elif so != 'H':
  585. if s[1] in os2:
  586. segs[k][1] = nv
  587. if s[3] in os2:
  588. segs[k][3] = nv
  589. else:
  590. if so == 'V' and s[0] in os2:
  591. segs[k][0] = segs[k][2] = nv
  592. elif so != 'V':
  593. if s[0] in os2:
  594. segs[k][0] = nv
  595. if s[2] in os2:
  596. segs[k][2] = nv
  597. changed = True
  598. return changed
  599. def merge_all_blocks(data, threshold=6):
  600. blocks = data.get('block', [])
  601. all_segs, counts = [], []
  602. for b in blocks:
  603. ss = [list(s) for s in b['points']]
  604. all_segs.extend(ss)
  605. counts.append(len(ss))
  606. ch = True
  607. while ch:
  608. ch = cluster_and_merge(all_segs, 'H', threshold) or cluster_and_merge(all_segs, 'V', threshold)
  609. idx = 0
  610. seen = set()
  611. for b, cnt in zip(blocks, counts):
  612. bs = []
  613. for s in all_segs[idx:idx + cnt]:
  614. k = (min((s[0], s[1]), (s[2], s[3])), max((s[0], s[1]), (s[2], s[3])))
  615. if not (s[0] == s[2] and s[1] == s[3]) and k not in seen:
  616. seen.add(k)
  617. bs.append(s)
  618. b['points'] = bs
  619. b['segment_count'] = len(bs)
  620. idx += cnt
  621. data['total_segments'] = sum(len(x['points']) for x in blocks)
  622. # ── Core pipeline ────────────────────────────────────────────────────────
  623. def run_pipeline(rgb_img, block_mask, room_model, furniture_model,
  624. merge_threshold, angle, dilation_kernel_size,
  625. center_threshold, expand_pixel, min_rect_short_side):
  626. clean = remove_edge_regions_image(rgb_img)
  627. _, gap_mask = extract_gaps_from_mask(block_mask)
  628. conn = extract_mask_region_from_arrays(clean, block_mask, gap_mask)
  629. jd, _, stats = merge_gap_fillers_from_arrays(
  630. block_mask, conn, "", dilation_kernel_size,
  631. center_threshold, expand_pixel, min_rect_short_side)
  632. jd["connect_area_stats"] = stats
  633. jd["block"] = build_block_data(rgb_img, block_mask, room_model)
  634. jd["furniture"] = detect_furniture(rgb_img, furniture_model)
  635. refine_blocks_in_data(jd)
  636. normalize_all(jd, angle)
  637. merge_all_blocks(jd, merge_threshold)
  638. return jd
  639. # ========================================================================
  640. # Endpoints
  641. # ========================================================================
  642. class ProcessRequest(BaseModel):
  643. folder: str = Field(..., description="包含 RGB 图片的文件夹路径")
  644. rgb_pattern: str | None = Field(None, description="RGB 文件名前缀(默认用文件夹名)")
  645. initial_model: str = Field("initial_mask.onnx")
  646. refine_model: str = Field("./black-forest-labs/FLUX.2-klein-4B")
  647. room_model: str = Field("room_cls.pt")
  648. furniture_model: str = Field("furniture_detect.onnx")
  649. merge_threshold: int = Field(15, ge=1)
  650. angle: int = Field(10, ge=0, le=45)
  651. dilation_kernel_size: int = Field(5, ge=1)
  652. center_threshold: int = Field(15, ge=1)
  653. expand_pixel: int = Field(2, ge=0)
  654. min_rect_short_side: int = Field(30, ge=1)
  655. @app.get("/health")
  656. def health():
  657. return {"status": "ok"}
  658. @app.post("/process")
  659. def process(req: ProcessRequest):
  660. """完整管线:RGB → initial_mask → refine_mask → JSON"""
  661. folder = req.folder
  662. if not os.path.isdir(folder):
  663. return {"success": False, "error": f"文件夹不存在: {folder}"}
  664. # Find RGB
  665. prefix = req.rgb_pattern or os.path.basename(folder.rstrip("/"))
  666. rgbs = [f for f in os.listdir(folder)
  667. if f.startswith(prefix)
  668. and not f.startswith("initial_mask_")
  669. and not f.startswith("refine_mask_")
  670. and f.lower().endswith((".png", ".jpg", ".jpeg"))]
  671. if not rgbs:
  672. return {"success": False, "error": f"未找到 RGB 图片 (前缀={prefix})"}
  673. rgb_name = rgbs[0]
  674. rgb_path = os.path.join(folder, rgb_name)
  675. base_name = os.path.splitext(rgb_name)[0]
  676. # All outputs go into a subfolder named after the image
  677. out_dir = os.path.join(folder, base_name)
  678. os.makedirs(out_dir, exist_ok=True)
  679. # Copy RGB into out_dir if not already there
  680. rgb_in_out = os.path.join(out_dir, rgb_name)
  681. if not os.path.exists(rgb_in_out):
  682. shutil.copy2(rgb_path, rgb_in_out)
  683. print(f"\n===== 开始处理 =====")
  684. print(f"RGB: {rgb_name}")
  685. print(f"输出目录: {out_dir}")
  686. try:
  687. # ── Stage 1: initial_mask ──────────────────────────────────
  688. im_path = os.path.join(out_dir, f"initial_mask_{rgb_name}")
  689. if os.path.exists(im_path):
  690. print(f"[1/3] initial_mask 已存在,跳过")
  691. else:
  692. print(f"[1/3] 生成 initial_mask ...")
  693. generate_initial_mask(rgb_in_out, im_path, req.initial_model, rgb_name)
  694. print(f" ✓ {im_path}")
  695. # ── Stage 2: refine_mask ───────────────────────────────────
  696. rm_path = os.path.join(out_dir, f"refine_mask_{rgb_name}")
  697. if os.path.exists(rm_path):
  698. print(f"[2/3] refine_mask 已存在,跳过")
  699. else:
  700. print(f"[2/3] 生成 refine_mask ...")
  701. generate_refine_mask(im_path, rm_path, req.refine_model)
  702. print(f" ✓ {rm_path}")
  703. # ── Stage 3: pipeline ──────────────────────────────────────
  704. print(f"[3/3] 运行管线 ...")
  705. rgb_img = cv2.imread(rgb_in_out)
  706. block_mask = cv2.imread(rm_path, cv2.IMREAD_GRAYSCALE)
  707. if rgb_img is None:
  708. return {"success": False, "error": f"无法读取 RGB: {rgb_in_out}"}
  709. if block_mask is None:
  710. return {"success": False, "error": f"无法读取掩码: {rm_path}"}
  711. jd = run_pipeline(
  712. rgb_img, block_mask,
  713. req.room_model, req.furniture_model,
  714. req.merge_threshold, req.angle,
  715. req.dilation_kernel_size, req.center_threshold,
  716. req.expand_pixel, req.min_rect_short_side,
  717. )
  718. out_json = os.path.join(out_dir, base_name + ".json")
  719. with open(out_json, "w", encoding="utf-8") as f:
  720. json.dump(jd, f, indent=2, ensure_ascii=False)
  721. print(f"\n===== 完成 =====")
  722. return {
  723. "success": True,
  724. "stats": {
  725. "connect_area_count": len(jd.get("connect_area", [])),
  726. "block_count": len(jd.get("block", [])),
  727. "furniture_count": len(jd.get("furniture", [])),
  728. "total_segments": jd.get("total_segments", 0),
  729. "output_folder": out_dir,
  730. "json_path": out_json,
  731. "initial_mask_path": im_path,
  732. "refine_mask_path": rm_path,
  733. },
  734. }
  735. except Exception as e:
  736. import traceback
  737. traceback.print_exc()
  738. return {"success": False, "error": str(e)}
  739. if __name__ == "__main__":
  740. import uvicorn
  741. uvicorn.run(app, host="0.0.0.0", port=8070)