Spaces:
Sleeping
Sleeping
| """ | |
| Wallpaper simulator — POC v7. | |
| New in v7: | |
| - Walls picked dynamically: after each wall, choose Refine / Add wall / Done. | |
| - Auto-mask detects bright outliers (windows, lights) in addition to colour | |
| differences (furniture, pipes...). | |
| - Per-wall refinement window: brush (add/remove) + magic wand (flood-fill) | |
| to fix anything the auto-mask missed. | |
| - Density slider inverted (right = larger motif). | |
| """ | |
| import argparse | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image, ImageOps | |
| CONFIG_WIN = "Configurez votre papier peint" | |
| def _put_text(img, text, org, scale=0.5, color=(30, 30, 30), thickness=1): | |
| cv2.putText(img, text, org, cv2.FONT_HERSHEY_SIMPLEX, scale, color, | |
| thickness, cv2.LINE_AA) | |
| def _filled_box(img, x1, y1, x2, y2, color, border=None): | |
| cv2.rectangle(img, (x1, y1), (x2, y2), color, -1) | |
| if border is not None: | |
| cv2.rectangle(img, (x1, y1), (x2, y2), border, 1) | |
| def config_dialog(default_width=300, default_height=250, default_density=40, | |
| density_min=10, density_max=100, pattern_name=""): | |
| """OpenCV-based dialog mimicking the wellpapers.com configurator card. | |
| Click ``-`` / ``+`` buttons or use keys w/W h/H to nudge the width and | |
| height by 10 cm (Shift / capital = +50). Drag the density slider or | |
| use the trackbar. Click 'CONFIGURER' / press Enter to confirm. | |
| """ | |
| W, H = 520, 440 | |
| state = { | |
| "width": int(default_width), | |
| "height": int(default_height), | |
| "density": int(default_density), | |
| "submitted": False, | |
| "cancelled": False, | |
| "dragging": False, | |
| } | |
| # Layout regions (x1, y1, x2, y2) | |
| R = { | |
| "w_minus": (30, 130, 60, 168), | |
| "w_plus": (180, 130, 210, 168), | |
| "h_minus": (260, 130, 290, 168), | |
| "h_plus": (410, 130, 440, 168), | |
| "slider": (30, 290, W - 30, 312), | |
| "cancel": (30, 370, 140, 410), | |
| "ok": (260, 370, W - 30, 410), | |
| } | |
| def hit(rect, x, y): | |
| x1, y1, x2, y2 = rect | |
| return x1 <= x <= x2 and y1 <= y <= y2 | |
| def slider_value_at(x): | |
| x1, _, x2, _ = R["slider"] | |
| x = max(x1, min(x2, x)) | |
| t = (x - x1) / max(1, x2 - x1) | |
| return int(round(density_min + t * (density_max - density_min))) | |
| def on_mouse(event, x, y, flags, param): | |
| if event == cv2.EVENT_LBUTTONDOWN: | |
| if hit(R["w_minus"], x, y): | |
| state["width"] = max(50, state["width"] - 10) | |
| elif hit(R["w_plus"], x, y): | |
| state["width"] = min(2000, state["width"] + 10) | |
| elif hit(R["h_minus"], x, y): | |
| state["height"] = max(50, state["height"] - 10) | |
| elif hit(R["h_plus"], x, y): | |
| state["height"] = min(1000, state["height"] + 10) | |
| elif hit(R["cancel"], x, y): | |
| state["cancelled"] = True | |
| elif hit(R["ok"], x, y): | |
| state["submitted"] = True | |
| elif hit(R["slider"], x, y) or (R["slider"][1] - 15 <= y <= R["slider"][3] + 15 | |
| and R["slider"][0] <= x <= R["slider"][2]): | |
| state["dragging"] = True | |
| state["density"] = slider_value_at(x) | |
| elif event == cv2.EVENT_MOUSEMOVE and state["dragging"]: | |
| state["density"] = slider_value_at(x) | |
| elif event == cv2.EVENT_LBUTTONUP: | |
| state["dragging"] = False | |
| def render(): | |
| img = np.full((H, W, 3), 250, dtype=np.uint8) | |
| _put_text(img, "CONFIGUREZ VOTRE PAPIER PEINT", (30, 30), | |
| scale=0.6, color=(20, 20, 20), thickness=2) | |
| if pattern_name: | |
| _put_text(img, pattern_name, (30, 52), scale=0.45, | |
| color=(120, 120, 120)) | |
| cv2.line(img, (30, 70), (W - 30, 70), (220, 220, 220), 1) | |
| _put_text(img, "Mesures", (30, 95), | |
| scale=0.55, color=(20, 20, 20), thickness=2) | |
| # Width box | |
| _filled_box(img, 20, 115, 220, 175, (255, 255, 255), (200, 200, 200)) | |
| _put_text(img, "LARGEUR (EN CM)", (28, 128), | |
| scale=0.36, color=(140, 140, 140)) | |
| _filled_box(img, *R["w_minus"], color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(img, "-", (39, 158), scale=0.9, thickness=2) | |
| _put_text(img, str(state["width"]), (75, 162), | |
| scale=1.0, thickness=2, color=(20, 20, 20)) | |
| _filled_box(img, *R["w_plus"], color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(img, "+", (188, 158), scale=0.9, thickness=2) | |
| # Height box | |
| _filled_box(img, 250, 115, 450, 175, (255, 255, 255), (200, 200, 200)) | |
| _put_text(img, "HAUTEUR (EN CM)", (258, 128), | |
| scale=0.36, color=(140, 140, 140)) | |
| _filled_box(img, *R["h_minus"], color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(img, "-", (269, 158), scale=0.9, thickness=2) | |
| _put_text(img, str(state["height"]), (305, 162), | |
| scale=1.0, thickness=2, color=(20, 20, 20)) | |
| _filled_box(img, *R["h_plus"], color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(img, "+", (418, 158), scale=0.9, thickness=2) | |
| area = (state["width"] * state["height"]) / 10000.0 | |
| _put_text(img, f"~{area:.1f} m2 (largeur x hauteur)", | |
| (30, 200), scale=0.43, color=(130, 130, 130)) | |
| cv2.line(img, (30, 220), (W - 30, 220), (220, 220, 220), 1) | |
| # Density slider | |
| _put_text(img, "Taille des motifs", (30, 248), | |
| scale=0.55, color=(20, 20, 20), thickness=2) | |
| _put_text(img, str(state["density"]), (30, 285), | |
| scale=1.2, thickness=3, color=(20, 20, 20)) | |
| sx1, sy, sx2, _ = R["slider"] | |
| cv2.line(img, (sx1, sy + 11), (sx2, sy + 11), (220, 220, 220), 5) | |
| t = int(sx1 + (state["density"] - density_min) / | |
| max(1, density_max - density_min) * (sx2 - sx1)) | |
| cv2.circle(img, (t, sy + 11), 11, (50, 210, 250), -1) | |
| cv2.circle(img, (t, sy + 11), 11, (180, 180, 180), 1) | |
| _put_text(img, f"min {density_min}", (sx1, sy + 38), | |
| scale=0.35, color=(150, 150, 150)) | |
| _put_text(img, f"max {density_max}", (sx2 - 50, sy + 38), | |
| scale=0.35, color=(150, 150, 150)) | |
| # Buttons | |
| _filled_box(img, *R["cancel"], color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(img, "ANNULER", (50, 395), scale=0.5, | |
| thickness=2, color=(80, 80, 80)) | |
| _filled_box(img, *R["ok"], color=(20, 200, 250)) | |
| _put_text(img, "CONFIGURER (Enter)", (272, 395), | |
| scale=0.55, thickness=2, color=(20, 20, 20)) | |
| return img | |
| cv2.namedWindow(CONFIG_WIN, cv2.WINDOW_AUTOSIZE) | |
| cv2.setMouseCallback(CONFIG_WIN, on_mouse) | |
| while True: | |
| cv2.imshow(CONFIG_WIN, render()) | |
| key = cv2.waitKey(20) & 0xFF | |
| if state["submitted"] or key in (13, 10): | |
| cv2.destroyWindow(CONFIG_WIN) | |
| return {"width": state["width"], "height": state["height"], | |
| "density": state["density"], "submitted": True} | |
| if state["cancelled"] or key in (ord('q'), 27): | |
| cv2.destroyWindow(CONFIG_WIN) | |
| return None | |
| if key == ord('w'): state["width"] = max(50, state["width"] - 10) | |
| elif key == ord('W'): state["width"] = min(2000, state["width"] + 10) | |
| elif key == ord('h'): state["height"] = max(50, state["height"] - 10) | |
| elif key == ord('H'): state["height"] = min(1000, state["height"] + 10) | |
| elif key == ord('['): state["density"] = max(density_min, state["density"] - 1) | |
| elif key == ord(']'): state["density"] = min(density_max, state["density"] + 1) | |
| # ---------- I/O ---------- | |
| def load_image(path: Path) -> np.ndarray: | |
| with Image.open(path) as im: | |
| im = ImageOps.exif_transpose(im).convert("RGB") | |
| arr = np.array(im) | |
| return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) | |
| # ---------- Geometry / texture ---------- | |
| def build_texture(pattern_bgr, target_w, target_h, repeats_x, mode): | |
| if mode == "panoramic": | |
| return cv2.resize(pattern_bgr, (target_w, target_h), interpolation=cv2.INTER_AREA) | |
| ph, pw = pattern_bgr.shape[:2] | |
| tile_w = max(1, int(round(target_w / max(repeats_x, 0.01)))) | |
| tile_h = max(1, int(round(tile_w * ph / pw))) | |
| tile = cv2.resize(pattern_bgr, (tile_w, tile_h), interpolation=cv2.INTER_AREA) | |
| cols = int(np.ceil(target_w / tile_w)) | |
| rows = int(np.ceil(target_h / tile_h)) | |
| return np.tile(tile, (rows, cols, 1))[:target_h, :target_w] | |
| def quad_mask(shape_hw, quad): | |
| h, w = shape_hw | |
| m = np.zeros((h, w), np.uint8) | |
| cv2.fillConvexPoly(m, quad.astype(np.int32), 255) | |
| return m | |
| _SEG_MODEL = None | |
| _SEG_PROCESSOR = None | |
| _SEG_PRED_CACHE: dict = {} | |
| WALL_CLASSES = {0} # ADE20K class index for 'wall' | |
| def get_seg_model(): | |
| global _SEG_MODEL, _SEG_PROCESSOR | |
| if _SEG_MODEL is None: | |
| import torch # noqa: F401 | |
| from transformers import (SegformerImageProcessor, | |
| SegformerForSemanticSegmentation) | |
| name = "nvidia/segformer-b2-finetuned-ade-512-512" | |
| print(" Loading SegFormer ADE20K weights (~250 MB first time)...") | |
| _SEG_PROCESSOR = SegformerImageProcessor.from_pretrained(name) | |
| _SEG_MODEL = SegformerForSemanticSegmentation.from_pretrained(name) | |
| _SEG_MODEL.eval() | |
| return _SEG_MODEL, _SEG_PROCESSOR | |
| def semantic_predict(photo_bgr): | |
| """Per-pixel ADE20K class labels for the photo. Cached by array id().""" | |
| key = id(photo_bgr) | |
| if key in _SEG_PRED_CACHE: | |
| return _SEG_PRED_CACHE[key] | |
| import torch | |
| from PIL import Image | |
| mdl, proc = get_seg_model() | |
| rgb = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2RGB) | |
| pil = Image.fromarray(rgb) | |
| inputs = proc(images=pil, return_tensors="pt") | |
| with torch.no_grad(): | |
| out = mdl(**inputs) | |
| h, w = photo_bgr.shape[:2] | |
| ups = torch.nn.functional.interpolate(out.logits, size=(h, w), | |
| mode="bilinear", align_corners=False) | |
| pred = ups.argmax(dim=1)[0].cpu().numpy().astype(np.int32) | |
| _SEG_PRED_CACHE[key] = pred | |
| return pred | |
| def semantic_wall_mask(photo_bgr, q_mask): | |
| """Mask = pixels classified as 'wall' by SegFormer, clipped to the quad.""" | |
| try: | |
| pred = semantic_predict(photo_bgr) | |
| except Exception as e: | |
| print(f" SegFormer error: {e}") | |
| return None | |
| wall = np.isin(pred, list(WALL_CLASSES)).astype(np.uint8) * 255 | |
| return cv2.bitwise_and(wall, q_mask) | |
| _SAM_MODEL = None | |
| def get_sam_model(): | |
| global _SAM_MODEL | |
| if _SAM_MODEL is None: | |
| from ultralytics import SAM | |
| weights = Path(__file__).parent / "mobile_sam.pt" | |
| weights_arg = str(weights) if weights.exists() else "mobile_sam.pt" | |
| print(" Loading MobileSAM weights...") | |
| _SAM_MODEL = SAM(weights_arg) | |
| return _SAM_MODEL | |
| def _heuristic_wall_mask(photo_bgr, q_mask, chroma_threshold=18.0, | |
| brightness_factor=2.5, min_brightness_gap=30.0): | |
| """Quick rough wall mask: chroma + brightness rejection, no morphological | |
| cleanup. Used to seed SAM prompt points.""" | |
| lab = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| L = lab[..., 0] | |
| ab = lab[..., 1:3] | |
| eroded = cv2.erode(q_mask, np.ones((25, 25), np.uint8)) | |
| sample_region = eroded if (eroded > 0).any() else q_mask | |
| samp_ab = ab[sample_region > 0].reshape(-1, 2) | |
| samp_L = L[sample_region > 0] | |
| if samp_ab.size == 0: | |
| return q_mask.copy() | |
| ref_ab = np.median(samp_ab, axis=0) | |
| L_mean, L_std = float(samp_L.mean()), float(samp_L.std()) | |
| gap = max(min_brightness_gap, brightness_factor * L_std) | |
| delta_ab = np.linalg.norm(ab - ref_ab, axis=-1) | |
| reject = ((delta_ab >= chroma_threshold) | |
| | (L > L_mean + gap) | (L < L_mean - gap)) & (q_mask > 0) | |
| rough = cv2.bitwise_and(q_mask, np.where(reject, 0, 255).astype(np.uint8)) | |
| return rough | |
| def _sample_points(mask, n): | |
| """Sample n approximately uniformly distributed points where mask>0.""" | |
| ys, xs = np.where(mask > 0) | |
| if len(ys) == 0: | |
| return [] | |
| if len(ys) < n: | |
| n = len(ys) | |
| idx = np.linspace(0, len(ys) - 1, n).astype(int) | |
| return [[int(xs[i]), int(ys[i])] for i in idx] | |
| STRICTNESS_MAP = { | |
| 0: (0, 0), | |
| 1: (12, 4), | |
| 2: (25, 10), | |
| 3: (45, 18), | |
| 4: (70, 30), | |
| } | |
| def sam_wall_mask(photo_bgr, q_mask, n_pos=5, n_neg=5, strictness=2): | |
| """Segment the wall inside the quadrilateral using MobileSAM. | |
| Strategy: | |
| 1. Compute a rough wall mask via colour+brightness heuristics. | |
| 2. Sample POSITIVE points deep inside the rough wall. | |
| 3. Sample NEGATIVE points deep inside the rejected zone (windows, | |
| furniture, etc.). | |
| 4. Send both as a SINGLE bundled prompt to SAM; SAM returns one mask | |
| that respects both constraints. Intersect with the quad. | |
| """ | |
| if (q_mask > 0).sum() < 500: | |
| return None | |
| rough_wall = _heuristic_wall_mask(photo_bgr, q_mask) | |
| not_wall = ((q_mask > 0) & (rough_wall == 0)).astype(np.uint8) * 255 | |
| pos_region = cv2.erode(rough_wall, np.ones((25, 25), np.uint8)) | |
| if (pos_region > 0).sum() < 200: | |
| pos_region = rough_wall | |
| pos = _sample_points(pos_region, n_pos) | |
| if not pos: | |
| return None | |
| neg_region = cv2.erode(not_wall, np.ones((15, 15), np.uint8)) | |
| if (neg_region > 0).sum() > 200: | |
| neg = _sample_points(neg_region, n_neg) | |
| else: | |
| neg = [] | |
| points = pos + neg | |
| labels = [1] * len(pos) + [0] * len(neg) | |
| try: | |
| model = get_sam_model() | |
| results = model.predict(photo_bgr, points=[points], labels=[labels], | |
| verbose=False) | |
| except Exception as e: | |
| print(f" SAM error: {e}") | |
| return None | |
| if not results or results[0].masks is None: | |
| return None | |
| sam_mask = (results[0].masks.data[0].cpu().numpy() > 0.5).astype(np.uint8) * 255 | |
| h_q, w_q = q_mask.shape | |
| if sam_mask.shape != (h_q, w_q): | |
| sam_mask = cv2.resize(sam_mask, (w_q, h_q), interpolation=cv2.INTER_NEAREST) | |
| out = cv2.bitwise_and(sam_mask, q_mask) | |
| close_o, dilate_o = STRICTNESS_MAP.get(int(strictness), STRICTNESS_MAP[2]) | |
| return _post_process_mask(out, q_mask, close_obj=close_o, dilate_obj=dilate_o) | |
| def _post_process_mask(wall_mask, q_mask, close_obj=25, dilate_obj=10, | |
| min_wall_blob_ratio=0.003): | |
| """Make object/window rejection sturdier: | |
| - close small holes in the rejected region (gaps between window bars, | |
| store slats, etc.) | |
| - dilate object contours to cover edge bleed | |
| - drop tiny isolated wall islands stranded inside an object | |
| """ | |
| quad_area = int((q_mask > 0).sum()) | |
| if quad_area == 0: | |
| return wall_mask | |
| not_wall = ((q_mask > 0) & (wall_mask == 0)).astype(np.uint8) * 255 | |
| if (not_wall > 0).any(): | |
| if close_obj > 0: | |
| k = np.ones((close_obj, close_obj), np.uint8) | |
| not_wall = cv2.morphologyEx(not_wall, cv2.MORPH_CLOSE, k) | |
| if dilate_obj > 0: | |
| k = np.ones((dilate_obj, dilate_obj), np.uint8) | |
| not_wall = cv2.dilate(not_wall, k) | |
| not_wall = cv2.bitwise_and(not_wall, q_mask) | |
| refined = cv2.bitwise_and(q_mask, cv2.bitwise_not(not_wall)) | |
| n_lbl, lbls, stats, _ = cv2.connectedComponentsWithStats(refined, connectivity=8) | |
| min_blob = max(500, int(min_wall_blob_ratio * quad_area)) | |
| out = np.zeros_like(refined) | |
| for i in range(1, n_lbl): | |
| if stats[i, cv2.CC_STAT_AREA] >= min_blob: | |
| out[lbls == i] = 255 | |
| return out | |
| def grabcut_refine(photo_bgr, q_mask, current_mask, max_dim=700, iters=3): | |
| """Refine the mask via GrabCut. | |
| GrabCut uses the photo's color GMM + smoothness term to snap the mask | |
| to actual image edges. Pixels currently marked wall are 'probable | |
| foreground', rejected pixels inside the quad are 'probable background', | |
| outside the quad is sure background. | |
| """ | |
| if (q_mask > 0).sum() < 1000: | |
| return current_mask | |
| h, w = photo_bgr.shape[:2] | |
| scale = min(1.0, max_dim / max(h, w)) | |
| if scale < 1: | |
| photo_s = cv2.resize(photo_bgr, None, fx=scale, fy=scale) | |
| q_s = cv2.resize(q_mask, (photo_s.shape[1], photo_s.shape[0]), | |
| interpolation=cv2.INTER_NEAREST) | |
| cur_s = cv2.resize(current_mask, (photo_s.shape[1], photo_s.shape[0]), | |
| interpolation=cv2.INTER_NEAREST) | |
| else: | |
| photo_s, q_s, cur_s = photo_bgr, q_mask, current_mask | |
| gc_mask = np.full(photo_s.shape[:2], cv2.GC_BGD, dtype=np.uint8) | |
| inside = q_s > 0 | |
| gc_mask[inside] = cv2.GC_PR_BGD | |
| gc_mask[inside & (cur_s > 0)] = cv2.GC_PR_FGD | |
| n_fg = int((gc_mask == cv2.GC_PR_FGD).sum()) | |
| n_bg = int((gc_mask == cv2.GC_PR_BGD).sum()) | |
| if n_fg < 200 or n_bg < 200: | |
| return current_mask | |
| bgd = np.zeros((1, 65), np.float64) | |
| fgd = np.zeros((1, 65), np.float64) | |
| try: | |
| cv2.grabCut(photo_s, gc_mask, None, bgd, fgd, iters, cv2.GC_INIT_WITH_MASK) | |
| except cv2.error: | |
| return current_mask | |
| refined = np.where((gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD), | |
| 255, 0).astype(np.uint8) | |
| if scale < 1: | |
| refined = cv2.resize(refined, (w, h), interpolation=cv2.INTER_NEAREST) | |
| return cv2.bitwise_and(refined, q_mask) | |
| def occlusion_mask(photo_bgr, q_mask, chroma_threshold, min_object_ratio=0.004, | |
| brightness_factor=2.5, min_brightness_gap=35.0, | |
| use_grabcut=False, use_sam=True, use_semantic=True, | |
| strictness=2): | |
| close_o, dilate_o = STRICTNESS_MAP.get(int(strictness), STRICTNESS_MAP[2]) | |
| if use_semantic: | |
| m = semantic_wall_mask(photo_bgr, q_mask) | |
| if m is not None and (m > 0).sum() > 500: | |
| return _post_process_mask(m, q_mask, close_obj=close_o, | |
| dilate_obj=dilate_o) | |
| print(" SegFormer gave no valid mask, falling back to SAM.") | |
| if use_sam: | |
| m = sam_wall_mask(photo_bgr, q_mask, strictness=strictness) | |
| if m is not None and (m > 0).sum() > 500: | |
| return m | |
| print(" SAM gave no valid mask, falling back to heuristics.") | |
| """Quadrilateral minus furniture/windows. | |
| Rejection rules inside the quad: | |
| 1. Chromaticity (a, b) far from wall median -> furniture, pipes... | |
| 2. Luminance (L) far from wall mean -> windows, lights, dark holes. | |
| Only LARGE connected rejection blobs are kept; small specks fold back in. | |
| """ | |
| lab = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| L = lab[..., 0] | |
| ab = lab[..., 1:3] | |
| eroded = cv2.erode(q_mask, np.ones((25, 25), np.uint8)) | |
| sample_region = eroded if (eroded > 0).any() else q_mask | |
| samples_ab = ab[sample_region > 0].reshape(-1, 2) | |
| samples_L = L[sample_region > 0] | |
| if samples_ab.size == 0: | |
| return q_mask | |
| ref_ab = np.median(samples_ab, axis=0) | |
| delta_ab = np.linalg.norm(ab - ref_ab, axis=-1) | |
| L_mean = float(samples_L.mean()) | |
| L_std = float(samples_L.std()) | |
| L_gap = max(min_brightness_gap, brightness_factor * L_std) | |
| chroma_off = delta_ab >= chroma_threshold | |
| bright_off = L > (L_mean + L_gap) | |
| dark_off = L < (L_mean - L_gap) | |
| reject = ((chroma_off | bright_off | dark_off) & (q_mask > 0)).astype(np.uint8) * 255 | |
| reject = cv2.morphologyEx(reject, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) | |
| quad_area = int((q_mask > 0).sum()) | |
| min_area = max(200, int(min_object_ratio * quad_area)) | |
| n_lbl, labels, stats, _ = cv2.connectedComponentsWithStats(reject, connectivity=8) | |
| big_reject = np.zeros_like(reject) | |
| for i in range(1, n_lbl): | |
| if stats[i, cv2.CC_STAT_AREA] >= min_area: | |
| big_reject[labels == i] = 255 | |
| big_reject = cv2.dilate(big_reject, np.ones((3, 3), np.uint8), iterations=1) | |
| mask = cv2.bitwise_and(q_mask, cv2.bitwise_not(big_reject)) | |
| mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((11, 11), np.uint8), iterations=2) | |
| if use_grabcut: | |
| mask = grabcut_refine(photo_bgr, q_mask, mask) | |
| mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) | |
| return mask | |
| def transfer_shading(photo_bgr, warped_bgr, wall_mask, strength): | |
| if strength <= 0: | |
| return warped_bgr | |
| lab = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| L = lab[..., 0] | |
| inside = wall_mask > 0 | |
| if not inside.any(): | |
| return warped_bgr | |
| L_mean = max(float(L[inside].mean()), 1e-3) | |
| shading = (L / L_mean).clip(0.3, 1.8) | |
| shading = 1.0 + (shading - 1.0) * strength | |
| warped_lab = cv2.cvtColor(warped_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| warped_lab[..., 0] = (warped_lab[..., 0] * shading).clip(0, 255) | |
| return cv2.cvtColor(warped_lab.astype(np.uint8), cv2.COLOR_LAB2BGR) | |
| def apply_wallpaper_on_quad(photo, canvas, quad, pattern, *, mode, repeats, | |
| shading_strength, feather, chroma_threshold, auto_mask, | |
| precomputed_mask=None): | |
| tl, tr, br, bl = quad | |
| rect_w = max(int(round(max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl)))), 2) | |
| rect_h = max(int(round(max(np.linalg.norm(bl - tl), np.linalg.norm(br - tr)))), 2) | |
| texture = build_texture(pattern, rect_w, rect_h, repeats, mode) | |
| src = np.array([[0, 0], [rect_w-1, 0], [rect_w-1, rect_h-1], [0, rect_h-1]], | |
| dtype=np.float32) | |
| H = cv2.getPerspectiveTransform(src, quad.astype(np.float32)) | |
| h_img, w_img = photo.shape[:2] | |
| warped = cv2.warpPerspective(texture, H, (w_img, h_img), flags=cv2.INTER_LINEAR) | |
| q = quad_mask((h_img, w_img), quad) | |
| if precomputed_mask is not None: | |
| mask = precomputed_mask | |
| elif auto_mask: | |
| mask = occlusion_mask(photo, q, chroma_threshold) | |
| else: | |
| mask = q | |
| shaded = transfer_shading(photo, warped, mask, shading_strength) | |
| if feather > 0: | |
| k = feather * 2 + 1 | |
| mask_f = cv2.GaussianBlur(mask, (k, k), 0) | |
| else: | |
| mask_f = mask | |
| alpha = (mask_f.astype(np.float32) / 255.0)[..., None] | |
| out = shaded.astype(np.float32) * alpha + canvas.astype(np.float32) * (1 - alpha) | |
| return out.clip(0, 255).astype(np.uint8), mask | |
| def render_all(photo, walls, pattern, *, mode, repeats=None, density=None, | |
| shading_strength=0.85, feather=2, chroma_threshold=14, | |
| auto_mask=True): | |
| """Render wallpaper on each wall. | |
| For tile mode: each wall has its own ``width_cm`` and we compute | |
| ``repeats_w = width_cm / density``. If ``density`` is None, fall back | |
| to the global ``repeats``. | |
| """ | |
| canvas = photo.copy() | |
| masks_dbg = np.zeros(photo.shape[:2], np.uint8) | |
| for entry in walls: | |
| quad = entry["quad"] | |
| pre = entry.get("mask") | |
| if mode == "tile" and density is not None and entry.get("width_cm"): | |
| wall_repeats = max(0.1, entry["width_cm"] / density) | |
| else: | |
| wall_repeats = repeats if repeats is not None else 4.0 | |
| canvas, m = apply_wallpaper_on_quad( | |
| photo, canvas, quad, pattern, | |
| mode=mode, repeats=wall_repeats, | |
| shading_strength=shading_strength, | |
| feather=feather, chroma_threshold=chroma_threshold, | |
| auto_mask=auto_mask, precomputed_mask=pre, | |
| ) | |
| masks_dbg = np.maximum(masks_dbg, m) | |
| return canvas, masks_dbg | |
| # ---------- Picker helpers ---------- | |
| PICKER_WIN = "Wallpaper Sim" | |
| def _btn(img, x1, y1, x2, y2, color, label, hot=True): | |
| cv2.rectangle(img, (x1, y1), (x2, y2), color, -1) | |
| if not hot: | |
| overlay = img.copy() | |
| cv2.rectangle(overlay, (x1, y1), (x2, y2), (50, 50, 50), -1) | |
| img[y1:y2, x1:x2] = cv2.addWeighted(overlay[y1:y2, x1:x2], 0.5, | |
| img[y1:y2, x1:x2], 0.5, 0) | |
| (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2) | |
| cv2.putText(img, label, (x1 + (x2 - x1 - tw) // 2, y1 + (y2 - y1 + th) // 2), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2) | |
| def _draw_walls(img, walls_disp, current=None, current_idx=None, total_walls=None): | |
| overlay = img.copy() | |
| for j, w in enumerate(walls_disp): | |
| poly = np.array(w, dtype=np.int32) | |
| cv2.fillPoly(overlay, [poly], (50, 50, 220)) | |
| cv2.polylines(img, [poly], True, (0, 0, 255), 3) | |
| c = poly.mean(axis=0).astype(int) | |
| cv2.putText(img, f"#{j+1}", tuple(c), cv2.FONT_HERSHEY_SIMPLEX, | |
| 1.6, (0, 0, 255), 5) | |
| if current: | |
| pts = np.array(current, dtype=np.int32) | |
| if len(current) >= 3: | |
| cv2.fillPoly(overlay, [pts], (60, 200, 60)) | |
| cv2.polylines(img, [pts], True, (0, 255, 0), 3) | |
| elif len(current) == 2: | |
| cv2.polylines(img, [pts], False, (0, 255, 0), 2) | |
| labels = ["TL", "TR", "BR", "BL"] | |
| for k, p in enumerate(current): | |
| cv2.circle(img, (int(p[0]), int(p[1])), 9, (0, 255, 0), -1) | |
| cv2.circle(img, (int(p[0]), int(p[1])), 9, (0, 0, 0), 2) | |
| cv2.putText(img, labels[k], (int(p[0]) + 12, int(p[1]) - 12), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 3) | |
| cv2.putText(img, labels[k], (int(p[0]) + 12, int(p[1]) - 12), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 1) | |
| return cv2.addWeighted(overlay, 0.25, img, 0.75, 0) | |
| # ---------- Step 1: pick 4 corners of one wall ---------- | |
| def pick_corners(disp_base, walls_disp, wall_idx): | |
| """Return list of 4 (x, y) display-space tuples, or None if cancelled.""" | |
| current = [] | |
| validate = {"v": False} | |
| disp_h, disp_w = disp_base.shape[:2] | |
| BTN_H = 60 | |
| bx1, by1, bx2, by2 = 0, disp_h - BTN_H, disp_w, disp_h | |
| def on_mouse(event, x, y, flags, param): | |
| if event != cv2.EVENT_LBUTTONDOWN: | |
| return | |
| if bx1 <= x <= bx2 and by1 <= y <= by2: | |
| if len(current) == 4: | |
| validate["v"] = True | |
| return | |
| if len(current) < 4: | |
| current.append((x, y)) | |
| cv2.setMouseCallback(PICKER_WIN, on_mouse) | |
| while True: | |
| img = _draw_walls(disp_base.copy(), walls_disp, current=current) | |
| header = (f"Mur {wall_idx+1} - {len(current)}/4 points " | |
| "(u=undo r=reset q=quit)") | |
| cv2.rectangle(img, (0, 0), (img.shape[1], 50), (0, 0, 0), -1) | |
| cv2.putText(img, header, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, | |
| (255, 255, 255), 2) | |
| ready = len(current) == 4 | |
| _btn(img, bx1, by1, bx2, by2, | |
| (0, 170, 0) if ready else (90, 90, 90), | |
| "VALIDER LES 4 POINTS (Enter)" if ready | |
| else f"Place {4-len(current)} point(s) restant(s)", | |
| hot=ready) | |
| cv2.imshow(PICKER_WIN, img) | |
| key = cv2.waitKey(20) & 0xFF | |
| if validate["v"] and len(current) == 4: | |
| return current | |
| if key in (13, 10) and len(current) == 4: | |
| return current | |
| if key in (ord('u'), ord('U')) and current: | |
| current.pop() | |
| elif key in (ord('r'), ord('R')): | |
| current.clear() | |
| elif key in (ord('q'), 27): | |
| cv2.destroyAllWindows() | |
| sys.exit("Annule par l'utilisateur.") | |
| # ---------- Step 2: review wall (refine / add / done) ---------- | |
| def review_wall(disp_base, walls_disp, current_disp, mask_disp, wall_idx, | |
| state_widths): | |
| """Show mask overlay + dimensions input + buttons. | |
| Returns ('refine'|'add'|'done'|'cancel', width_cm). | |
| `state_widths` is the running list of widths chosen so far (used as default).""" | |
| disp_h, disp_w = disp_base.shape[:2] | |
| BTN_H = 70 | |
| bw = disp_w // 4 | |
| btns = { | |
| "refine": (0, disp_h - BTN_H, bw, disp_h, (180, 100, 0), "RAFFINER (R)"), | |
| "add": (bw, disp_h - BTN_H, 2 * bw, disp_h, (180, 100, 0), "+ MUR SUIVANT (A)"), | |
| "cancel": (2 * bw, disp_h - BTN_H, 3 * bw, disp_h, (60, 60, 60), "ANNULER MUR (C)"), | |
| "done": (3 * bw, disp_h - BTN_H, disp_w, disp_h, (0, 170, 0), "TERMINER (Enter)"), | |
| } | |
| # Width input controls on top-right | |
| W_FIELD = (disp_w - 380, 60, disp_w - 20, 110) | |
| W_MINUS = (disp_w - 380, 60, disp_w - 320, 110) | |
| W_PLUS = (disp_w - 80, 60, disp_w - 20, 110) | |
| default_w = int(state_widths[-1]) if state_widths else 300 | |
| state = {"action": None, "width": default_w} | |
| def on_mouse(event, x, y, flags, param): | |
| if event != cv2.EVENT_LBUTTONDOWN: | |
| return | |
| # Width adjust buttons | |
| if W_MINUS[0] <= x <= W_MINUS[2] and W_MINUS[1] <= y <= W_MINUS[3]: | |
| state["width"] = max(50, state["width"] - 10) | |
| return | |
| if W_PLUS[0] <= x <= W_PLUS[2] and W_PLUS[1] <= y <= W_PLUS[3]: | |
| state["width"] = min(2000, state["width"] + 10) | |
| return | |
| # Action buttons | |
| for k, (x1, y1, x2, y2, _, _) in btns.items(): | |
| if x1 <= x <= x2 and y1 <= y <= y2: | |
| state["action"] = k | |
| return | |
| cv2.setMouseCallback(PICKER_WIN, on_mouse) | |
| walls_for_draw = walls_disp + [current_disp] | |
| while state["action"] is None: | |
| base = _draw_walls(disp_base.copy(), walls_for_draw) | |
| if mask_disp is not None: | |
| ovr = base.copy() | |
| ovr[mask_disp > 0] = ovr[mask_disp > 0] * 0.3 + np.array([0, 255, 0]) * 0.7 | |
| base = cv2.addWeighted(ovr.astype(np.uint8), 0.55, base, 0.45, 0) | |
| quad_mask_disp = np.zeros_like(mask_disp) | |
| cv2.fillConvexPoly(quad_mask_disp, | |
| np.array(current_disp, dtype=np.int32), 255) | |
| rejected = (quad_mask_disp > 0) & (mask_disp == 0) | |
| r_ovr = base.copy() | |
| r_ovr[rejected] = r_ovr[rejected] * 0.3 + np.array([0, 0, 255]) * 0.7 | |
| base = cv2.addWeighted(r_ovr.astype(np.uint8), 0.45, base, 0.55, 0) | |
| header = (f"Mur {wall_idx+1} - vert = papier rouge = preserve " | |
| "(objets, fenetres)") | |
| cv2.rectangle(base, (0, 0), (base.shape[1], 50), (0, 0, 0), -1) | |
| cv2.putText(base, header, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.85, | |
| (255, 255, 255), 2) | |
| # Width input | |
| _filled_box(base, W_FIELD[0] - 6, W_FIELD[1] - 6, | |
| W_FIELD[2] + 6, W_FIELD[3] + 6, | |
| color=(255, 255, 255), border=(180, 180, 180)) | |
| _put_text(base, "LARGEUR DU MUR (CM)", (W_FIELD[0] + 4, W_FIELD[1] + 12), | |
| scale=0.4, color=(120, 120, 120)) | |
| _filled_box(base, *W_MINUS, color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(base, "-", (W_MINUS[0] + 22, W_MINUS[3] - 12), | |
| scale=1.1, thickness=3, color=(40, 40, 40)) | |
| _put_text(base, str(state["width"]), | |
| (W_MINUS[2] + 20, W_FIELD[3] - 12), | |
| scale=1.1, thickness=3, color=(20, 20, 20)) | |
| _filled_box(base, *W_PLUS, color=(230, 230, 230), | |
| border=(180, 180, 180)) | |
| _put_text(base, "+", (W_PLUS[0] + 22, W_PLUS[3] - 12), | |
| scale=1.1, thickness=3, color=(40, 40, 40)) | |
| _put_text(base, "w/W = -10/+10 (10/50 cm)", | |
| (W_FIELD[0], W_FIELD[3] + 24), | |
| scale=0.4, color=(120, 120, 120)) | |
| for x1, y1, x2, y2, col, label in btns.values(): | |
| _btn(base, x1, y1, x2, y2, col, label) | |
| cv2.imshow(PICKER_WIN, base) | |
| key = cv2.waitKey(20) & 0xFF | |
| if key in (13, 10): state["action"] = "done" | |
| elif key in (ord('r'), ord('R')): state["action"] = "refine" | |
| elif key in (ord('a'), ord('A')): state["action"] = "add" | |
| elif key in (ord('c'), ord('C')): state["action"] = "cancel" | |
| elif key == ord('w'): state["width"] = max(50, state["width"] - 10) | |
| elif key == ord('W'): state["width"] = min(2000, state["width"] + 50) | |
| elif key == 27 or key == ord('q'): | |
| cv2.destroyAllWindows() | |
| sys.exit("Annule par l'utilisateur.") | |
| return state["action"], state["width"] | |
| # ---------- Step 3: brush + magic wand refinement ---------- | |
| def refine_mask(disp_photo, mask, quad_mask_disp): | |
| """Interactive refinement. Returns the updated mask (in disp resolution).""" | |
| state = { | |
| "mode": "add", # add | remove | wand_add | wand_remove | |
| "brush": 30, | |
| "wand_tol": 12, | |
| "drawing": False, | |
| "last": None, | |
| "undo": [], | |
| } | |
| h, w = disp_photo.shape[:2] | |
| BTN_H = 60 | |
| bw = w // 6 | |
| btns = { | |
| "add": (0, h - BTN_H, bw, h, (0, 150, 0), "Brosse + (1)"), | |
| "remove": (bw, h - BTN_H, 2 * bw, h, (0, 0, 150), "Brosse - (2)"), | |
| "wand_add": (2 * bw, h - BTN_H, 3 * bw, h, (0, 150, 150),"Wand + (3)"), | |
| "wand_rem": (3 * bw, h - BTN_H, 4 * bw, h, (150, 0, 150),"Wand - (4)"), | |
| "reset": (4 * bw, h - BTN_H, 5 * bw, h, (60, 60, 60), "Reset (r)"), | |
| "ok": (5 * bw, h - BTN_H, w, h, (0, 170, 0), "VALIDER (Enter)"), | |
| } | |
| mode_map = {"add": "add", "remove": "remove", | |
| "wand_add": "wand_add", "wand_rem": "wand_remove"} | |
| done = {"v": False} | |
| def commit(): | |
| state["undo"].append(mask.copy()) | |
| if len(state["undo"]) > 20: | |
| state["undo"].pop(0) | |
| def apply_brush_point(x, y): | |
| val = 255 if state["mode"] == "add" else 0 | |
| cv2.circle(mask, (x, y), state["brush"], val, -1) | |
| if val == 255: | |
| np.bitwise_and(mask, quad_mask_disp, out=mask) | |
| def apply_brush_line(p1, p2): | |
| val = 255 if state["mode"] == "add" else 0 | |
| cv2.line(mask, p1, p2, val, state["brush"] * 2) | |
| if val == 255: | |
| np.bitwise_and(mask, quad_mask_disp, out=mask) | |
| def apply_wand(x, y): | |
| flood = np.zeros((h + 2, w + 2), dtype=np.uint8) | |
| tol = state["wand_tol"] | |
| flags = 4 | (255 << 8) | cv2.FLOODFILL_MASK_ONLY | cv2.FLOODFILL_FIXED_RANGE | |
| cv2.floodFill(disp_photo.copy(), flood, (x, y), 0, | |
| loDiff=(tol, tol, tol), upDiff=(tol, tol, tol), flags=flags) | |
| region = (flood[1:-1, 1:-1] > 0).astype(np.uint8) * 255 | |
| region = cv2.bitwise_and(region, quad_mask_disp) | |
| if state["mode"] == "wand_add": | |
| np.maximum(mask, region, out=mask) | |
| else: | |
| mask[region > 0] = 0 | |
| def on_mouse(event, x, y, flags, param): | |
| if event == cv2.EVENT_LBUTTONDOWN: | |
| for k, (x1, y1, x2, y2, *_) in btns.items(): | |
| if x1 <= x <= x2 and y1 <= y <= y2: | |
| if k == "ok": | |
| done["v"] = True | |
| elif k == "reset": | |
| commit() | |
| mask[:] = state["undo"][0] if False else mask # noop placeholder | |
| elif k in mode_map: | |
| state["mode"] = mode_map[k] | |
| return | |
| commit() | |
| if state["mode"].startswith("wand"): | |
| apply_wand(x, y) | |
| else: | |
| state["drawing"] = True | |
| state["last"] = (x, y) | |
| apply_brush_point(x, y) | |
| elif event == cv2.EVENT_MOUSEMOVE and state["drawing"]: | |
| if state["mode"] in ("add", "remove") and state["last"]: | |
| apply_brush_line(state["last"], (x, y)) | |
| state["last"] = (x, y) | |
| elif event == cv2.EVENT_LBUTTONUP: | |
| state["drawing"] = False | |
| state["last"] = None | |
| cv2.setMouseCallback(PICKER_WIN, on_mouse) | |
| initial_mask = mask.copy() | |
| while not done["v"]: | |
| img = disp_photo.copy() | |
| # green overlay where wall, red where excluded inside quad | |
| wall_pix = mask > 127 | |
| ovr = img.copy().astype(np.float32) | |
| ovr[wall_pix] = ovr[wall_pix] * 0.55 + np.array([0, 200, 0]) * 0.45 | |
| excl_pix = (quad_mask_disp > 0) & (~wall_pix) | |
| ovr[excl_pix] = ovr[excl_pix] * 0.55 + np.array([0, 0, 200]) * 0.45 | |
| img = ovr.clip(0, 255).astype(np.uint8) | |
| header = (f"Mode: {state['mode']} | Brosse: {state['brush']}px " | |
| f"| Tol wand: {state['wand_tol']} | " | |
| "[/]=brosse ,/.=tol u=undo r=reset") | |
| cv2.rectangle(img, (0, 0), (img.shape[1], 38), (0, 0, 0), -1) | |
| cv2.putText(img, header, (10, 26), cv2.FONT_HERSHEY_SIMPLEX, 0.55, | |
| (255, 255, 255), 1) | |
| for k, (x1, y1, x2, y2, col, label) in btns.items(): | |
| active = (k in mode_map and mode_map[k] == state["mode"]) | |
| actual_col = tuple(int(c * 1.4) if active else c for c in col) | |
| _btn(img, x1, y1, x2, y2, actual_col, label) | |
| cv2.imshow(PICKER_WIN, img) | |
| key = cv2.waitKey(20) & 0xFF | |
| if key == ord('1'): state["mode"] = "add" | |
| elif key == ord('2'): state["mode"] = "remove" | |
| elif key == ord('3'): state["mode"] = "wand_add" | |
| elif key == ord('4'): state["mode"] = "wand_remove" | |
| elif key == ord('['): state["brush"] = max(5, state["brush"] - 5) | |
| elif key == ord(']'): state["brush"] = min(200, state["brush"] + 5) | |
| elif key == ord(','): state["wand_tol"] = max(2, state["wand_tol"] - 2) | |
| elif key == ord('.'): state["wand_tol"] = min(60, state["wand_tol"] + 2) | |
| elif key == ord('u') and state["undo"]: | |
| mask[:] = state["undo"].pop() | |
| elif key == ord('r'): | |
| commit() | |
| mask[:] = initial_mask | |
| elif key in (13, 10): done["v"] = True | |
| elif key in (ord('q'), 27): | |
| mask[:] = initial_mask | |
| break | |
| return mask | |
| # ---------- Top-level picker ---------- | |
| def pick_walls_dynamic(photo_bgr, args, pad_ratio=0.25): | |
| h, w = photo_bgr.shape[:2] | |
| pad_w = int(w * pad_ratio) | |
| pad_h = int(h * pad_ratio) | |
| padded = cv2.copyMakeBorder(photo_bgr, pad_h, pad_h, pad_w, pad_w, | |
| cv2.BORDER_CONSTANT, value=(40, 40, 40)) | |
| max_w = 1500 | |
| scale = min(1.0, max_w / padded.shape[1]) | |
| disp_base = cv2.resize(padded, None, fx=scale, fy=scale) if scale < 1 else padded.copy() | |
| walls_disp: list[list[tuple[int, int]]] = [] | |
| wall_idx = 0 | |
| cv2.namedWindow(PICKER_WIN, cv2.WINDOW_AUTOSIZE) | |
| # Helper: convert one disp wall to photo-coords quad | |
| def disp_to_photo(pts): | |
| arr = np.array(pts, dtype=np.float32) / scale | |
| return arr - np.array([pad_w, pad_h], dtype=np.float32) | |
| # For mask computation we need a downscaled photo of disp_base size | |
| photo_for_disp = cv2.resize(photo_bgr, (disp_base.shape[1] - 0, | |
| disp_base.shape[0] - 0)) | |
| # Actually we want a disp_base-sized version of the photo. disp_base contains padding. | |
| # Build a padded version of photo at disp scale: | |
| photo_padded = cv2.copyMakeBorder(photo_bgr, pad_h, pad_h, pad_w, pad_w, | |
| cv2.BORDER_CONSTANT, value=(40, 40, 40)) | |
| photo_disp = (cv2.resize(photo_padded, (disp_base.shape[1], disp_base.shape[0])) | |
| if scale < 1 else photo_padded.copy()) | |
| wall_masks_full: list[np.ndarray] = [] | |
| wall_widths_cm: list[float] = [] | |
| full_h, full_w = photo_bgr.shape[:2] | |
| def _full_to_disp(mask_full): | |
| padded = np.zeros((photo_padded.shape[0], photo_padded.shape[1]), np.uint8) | |
| padded[pad_h:pad_h + full_h, pad_w:pad_w + full_w] = mask_full | |
| return cv2.resize(padded, (disp_base.shape[1], disp_base.shape[0]), | |
| interpolation=cv2.INTER_NEAREST) | |
| def _disp_to_full(mask_disp): | |
| padded = cv2.resize(mask_disp, | |
| (photo_padded.shape[1], photo_padded.shape[0]), | |
| interpolation=cv2.INTER_NEAREST) | |
| return padded[pad_h:pad_h + full_h, pad_w:pad_w + full_w] | |
| while True: | |
| current = pick_corners(disp_base, walls_disp, wall_idx) | |
| # Convert to full-res photo coords and compute mask there. | |
| current_photo = disp_to_photo(current) | |
| q_full = quad_mask(photo_bgr.shape[:2], current_photo) | |
| print(f" Computing mask for wall {wall_idx + 1}...") | |
| if args.auto_mask: | |
| mask_full = occlusion_mask(photo_bgr, q_full, args.chroma_threshold, | |
| use_semantic=args.semantic, | |
| use_sam=args.sam, | |
| strictness=args.strictness) | |
| else: | |
| mask_full = q_full.copy() | |
| mask_disp = _full_to_disp(mask_full) | |
| q_disp = np.zeros(disp_base.shape[:2], np.uint8) | |
| cv2.fillConvexPoly(q_disp, np.array(current, dtype=np.int32), 255) | |
| while True: | |
| action, width_cm = review_wall(disp_base, walls_disp, current, | |
| mask_disp, wall_idx, wall_widths_cm) | |
| if action == "refine": | |
| mask_disp = refine_mask(photo_disp.copy(), mask_disp.copy(), q_disp) | |
| mask_full = _disp_to_full(mask_disp) | |
| elif action == "cancel": | |
| break | |
| elif action in ("add", "done"): | |
| walls_disp.append(current) | |
| wall_masks_full.append(mask_full) | |
| wall_widths_cm.append(float(width_cm)) | |
| wall_idx += 1 | |
| break | |
| if action == "done": | |
| break | |
| if action == "cancel": | |
| continue | |
| cv2.destroyWindow(PICKER_WIN) | |
| walls_entries = [] | |
| for w_disp, m_full, w_cm in zip(walls_disp, wall_masks_full, wall_widths_cm): | |
| quad_photo = disp_to_photo(w_disp) | |
| walls_entries.append({"quad": quad_photo, "mask": m_full, | |
| "width_cm": w_cm}) | |
| return walls_entries | |
| # ---------- Density preview ---------- | |
| def interactive_preview(photo, walls, pattern, args): | |
| h, w = photo.shape[:2] | |
| max_w = 1300 | |
| scale = min(1.0, max_w / w) | |
| if scale < 1: | |
| prev_photo = cv2.resize(photo, None, fx=scale, fy=scale) | |
| prev_walls = [] | |
| for e in walls: | |
| q = np.asarray(e["quad"], dtype=np.float32) * scale | |
| m = cv2.resize(e["mask"], (prev_photo.shape[1], prev_photo.shape[0]), | |
| interpolation=cv2.INTER_NEAREST) | |
| entry = {"quad": q, "mask": m, "width_cm": e.get("width_cm")} | |
| prev_walls.append(entry) | |
| else: | |
| prev_photo = photo.copy() | |
| prev_walls = walls | |
| is_tile = (args.mode == "tile") | |
| d_min = int(args.density_min) | |
| d_max = int(args.density_max) | |
| initial_density = max(d_min, min(d_max, int(args.density))) if is_tile else None | |
| BAR_H = 90 | |
| canvas_w = prev_photo.shape[1] | |
| sx1, sx2 = 30, canvas_w - 30 | |
| sy_offset = 50 | |
| state = {"density": initial_density, "dirty": True, "img": None, | |
| "dragging": False} | |
| def density_at(x): | |
| t = max(0.0, min(1.0, (x - sx1) / max(1, sx2 - sx1))) | |
| return int(round(d_min + t * (d_max - d_min))) | |
| def on_mouse(event, x, y, flags, param): | |
| if not is_tile: | |
| return | |
| bar_top = prev_photo.shape[0] | |
| if event == cv2.EVENT_LBUTTONDOWN: | |
| sy_abs = bar_top + sy_offset | |
| if abs(y - sy_abs) <= 25 and sx1 - 15 <= x <= sx2 + 15: | |
| state["dragging"] = True | |
| state["density"] = density_at(x) | |
| state["dirty"] = True | |
| elif event == cv2.EVENT_MOUSEMOVE and state["dragging"]: | |
| state["density"] = density_at(x) | |
| state["dirty"] = True | |
| elif event == cv2.EVENT_LBUTTONUP: | |
| state["dragging"] = False | |
| win = ("Preview densite - drag slider - S/Enter export - Q annuler" | |
| if is_tile else | |
| "Preview panoramique - S/Enter export - Q annuler") | |
| cv2.namedWindow(win, cv2.WINDOW_AUTOSIZE) | |
| cv2.setMouseCallback(win, on_mouse) | |
| while True: | |
| if state["dirty"]: | |
| density = state["density"] | |
| canvas, _ = render_all( | |
| prev_photo, prev_walls, pattern, | |
| mode=args.mode, | |
| density=density if is_tile else None, | |
| shading_strength=args.shading_strength, | |
| feather=args.feather, | |
| chroma_threshold=args.chroma_threshold, | |
| auto_mask=args.auto_mask, | |
| ) | |
| full = np.full((canvas.shape[0] + BAR_H, canvas.shape[1], 3), | |
| 35, dtype=np.uint8) | |
| full[:canvas.shape[0]] = canvas | |
| if is_tile: | |
| sy = canvas.shape[0] + sy_offset | |
| _put_text(full, f"Densite = {density} cm", | |
| (sx1, sy - 16), scale=0.7, thickness=2, | |
| color=(255, 255, 255)) | |
| cv2.line(full, (sx1, sy), (sx2, sy), (110, 110, 110), 5) | |
| t = (density - d_min) / max(1, d_max - d_min) | |
| tx = int(sx1 + t * (sx2 - sx1)) | |
| cv2.circle(full, (tx, sy), 12, (50, 210, 250), -1) | |
| cv2.circle(full, (tx, sy), 12, (200, 200, 200), 1) | |
| _put_text(full, str(d_min), (sx1 - 4, sy + 26), | |
| scale=0.45, color=(170, 170, 170)) | |
| _put_text(full, str(d_max), (sx2 - 28, sy + 26), | |
| scale=0.45, color=(170, 170, 170)) | |
| info_y = canvas.shape[0] + BAR_H - 12 | |
| parts = [] | |
| for i, e in enumerate(prev_walls, 1): | |
| wc = e.get("width_cm") | |
| if wc: | |
| parts.append(f"mur{i}={int(wc)}cm/~{wc/density:.1f}rep") | |
| _put_text(full, " ".join(parts), (sx1, info_y), | |
| scale=0.45, color=(190, 190, 190)) | |
| else: | |
| cy = canvas.shape[0] + BAR_H // 2 + 6 | |
| _put_text(full, | |
| "Mode panoramique : motif etire sur chaque mur. " | |
| "S/Enter = exporter - Q = annuler", | |
| (sx1, cy), scale=0.6, thickness=2, | |
| color=(255, 255, 255)) | |
| state["img"] = full | |
| state["dirty"] = False | |
| cv2.imshow(win, state["img"]) | |
| key = cv2.waitKey(30) & 0xFF | |
| if key in (ord('s'), ord('S'), 13, 10): | |
| cv2.destroyWindow(win) | |
| return state["density"] | |
| if key in (ord('q'), 27): | |
| cv2.destroyAllWindows() | |
| sys.exit("Annule par l'utilisateur.") | |
| if is_tile and key == ord('['): | |
| state["density"] = max(d_min, state["density"] - 1); state["dirty"] = True | |
| elif is_tile and key == ord(']'): | |
| state["density"] = min(d_max, state["density"] + 1); state["dirty"] = True | |
| # ---------- Main ---------- | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Wallpaper simulator POC v7") | |
| ap.add_argument("--photo", required=True, type=Path) | |
| ap.add_argument("--pattern", required=True, type=Path) | |
| ap.add_argument("--mode", choices=["tile", "panoramic"], default="tile") | |
| ap.add_argument("--density", type=int, default=40, | |
| help="Pattern repetition width in cm — same scale as on " | |
| "wellpapers.com (e.g. Stripes & Swing: 10-100, default 40)") | |
| ap.add_argument("--density-min", type=int, default=10) | |
| ap.add_argument("--density-max", type=int, default=100) | |
| ap.add_argument("--wall-width", type=float, default=300.0, | |
| help="Real wall width in cm (used for all walls). Default 300.") | |
| ap.add_argument("--shading-strength", type=float, default=0.85) | |
| ap.add_argument("--feather", type=int, default=2) | |
| ap.add_argument("--auto-mask", action=argparse.BooleanOptionalAction, default=True) | |
| ap.add_argument("--chroma-threshold", type=float, default=14.0) | |
| ap.add_argument("--min-object-ratio", type=float, default=0.004) | |
| ap.add_argument("--semantic", action=argparse.BooleanOptionalAction, default=True, | |
| help="Use SegFormer ADE20K for wall segmentation (default on)") | |
| ap.add_argument("--sam", action=argparse.BooleanOptionalAction, default=True, | |
| help="Fallback to MobileSAM if semantic fails (default on)") | |
| ap.add_argument("--strictness", type=int, default=2, | |
| help="Object rejection strictness 0-4. Higher = more " | |
| "aggressive (closes window gaps, eats further into " | |
| "object edges).") | |
| ap.add_argument("--out", type=Path, default=Path("out.png")) | |
| args = ap.parse_args() | |
| photo = load_image(args.photo) | |
| pattern = load_image(args.pattern) | |
| print(f"Photo : {args.photo.name} {photo.shape[1]}x{photo.shape[0]}") | |
| print(f"Motif : {args.pattern.name}") | |
| print(f"Densite range : {args.density_min}-{args.density_max} cm (def {args.density})") | |
| print(f"Largeur mur par defaut : {int(args.wall_width)} cm") | |
| print("Workflow : Photo -> Pick corners -> Mesures -> Mur suivant -> Densite") | |
| walls = pick_walls_dynamic(photo, args) | |
| if not walls: | |
| sys.exit("Aucun mur selectionne.") | |
| print(f"Murs selectionnes : {len(walls)}") | |
| final_density = interactive_preview(photo, walls, pattern, args) | |
| if args.mode == "tile": | |
| print(f"Densite finale : {final_density} cm (largeur d'une " | |
| f"repetition du motif). Achetable sur wellpapers.com avec " | |
| f"le slider 'Taille des motifs' = {final_density}.") | |
| canvas, masks_dbg = render_all( | |
| photo, walls, pattern, | |
| mode=args.mode, density=final_density, | |
| shading_strength=args.shading_strength, | |
| feather=args.feather, | |
| chroma_threshold=args.chroma_threshold, | |
| auto_mask=args.auto_mask, | |
| ) | |
| args.out.parent.mkdir(parents=True, exist_ok=True) | |
| cv2.imwrite(str(args.out), canvas) | |
| cmp_p = args.out.with_name(args.out.stem + "_compare" + args.out.suffix) | |
| cv2.imwrite(str(cmp_p), np.concatenate([photo, canvas], axis=1)) | |
| mask_p = args.out.with_name(args.out.stem + "_mask" + args.out.suffix) | |
| cv2.imwrite(str(mask_p), masks_dbg) | |
| print(f"Sortie : {args.out}") | |
| print(f"Avant / apres : {cmp_p}") | |
| try: | |
| subprocess.run(["open", str(cmp_p)], check=False) | |
| except Exception: | |
| pass | |
| if __name__ == "__main__": | |
| main() | |