# Stage 16 (diagnosis only — writes nothing): measure the three defects Jeremy named, so the # fixes target what is actually there instead of repeating stages 11-15. # # blender --background --python 16_diagnose.py -- [scratch_dir] # # 1. CUT LINES. Are they still topology (disconnected panel runs / holes) after stage 15's weld, # or are they now a purely geometric groove? Reports boundary edges, non-manifold edges, # degenerate faces, and — for the strongest shading-kink clusters — the groove depth in mm # measured perpendicular to the line. Depth tells us whether to weld harder or to fillet. # 2. DISCOLOURATION. Finds the repainted texels by diffing this blend's packed basecolor against # the ORIGINAL texture, then reports mean RGB inside the patch vs a ring of untouched skin # just outside it. A tone STEP at the boundary is a Poisson problem; a uniform offset over the # whole patch is a levelling problem. The numbers separate them. # 3. CLEAVAGE. Samples the medial (sternum) corridor for concavity: minimum principal-curvature # radius per height, so "sharp crease" vs "round fillet" is a number, not an opinion. import bpy, bmesh, sys, os, time, math import numpy as np argv = sys.argv[sys.argv.index("--") + 1:] BLEND = argv[0] ORIG = argv[1] if len(argv) > 1 else "" SCRATCH = argv[2] if len(argv) > 2 else "." t0 = time.time() def log(m): print(f"[diag {time.time()-t0:6.1f}s] {m}", flush=True) def body_of(): return max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) # ============================================================================= # 1. TOPOLOGY + CUT LINES # ============================================================================= bpy.ops.wm.open_mainfile(filepath=BLEND) ob = body_of() me = ob.data n_v = len(me.vertices) n_f = len(me.polygons) log(f"mesh '{ob.name}': {n_v} verts, {n_f} faces, custom_normals={me.has_custom_normals}") co = np.empty(n_v * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) print(f"BBOX z {co[:,2].min():.3f}..{co[:,2].max():.3f} " f"x {co[:,0].min():.3f}..{co[:,0].max():.3f} y {co[:,1].min():.3f}..{co[:,1].max():.3f}") bm = bmesh.new() bm.from_mesh(me) bnd = [e for e in bm.edges if len(e.link_faces) == 1] nonman = [e for e in bm.edges if len(e.link_faces) > 2] degen = [f for f in bm.faces if f.calc_area() < 1e-12] loose = [v for v in bm.verts if not v.link_faces] print(f"TOPO boundary_edges={len(bnd)} nonmanifold_edges={len(nonman)} " f"degenerate_faces={len(degen)} loose_verts={len(loose)}") # where are the holes? cluster boundary verts by height if bnd: bz = np.array([v.co.z for e in bnd for v in e.verts]) hist, edges = np.histogram(bz, bins=12) print("BOUNDARY-EDGE z histogram (holes live here):") for c, lo, hi in zip(hist, edges[:-1], edges[1:]): if c: print(f" z {lo:.3f}-{hi:.3f}: {c}") bm.free() # ---- shading kinks = the visible lines ---- nrm = np.empty(n_v * 3) me.vertices.foreach_get("normal", nrm) nrm = nrm.reshape(-1, 3) ev = np.empty(len(me.edges) * 2, dtype=np.int32) me.edges.foreach_get("vertices", ev) ev = ev.reshape(-1, 2) order = np.concatenate([ev[:, 0], ev[:, 1]]) nbr = np.concatenate([ev[:, 1], ev[:, 0]]) srt = np.argsort(order, kind="stable") o_s, n_s = order[srt], nbr[srt] ptr = np.searchsorted(o_s, np.arange(n_v + 1)) cnt = np.maximum(np.diff(ptr), 1) def nbr_mean(X): acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0) empty = np.diff(ptr) == 0 acc[empty] = X[empty] return acc / cnt[:, None] N = nrm.copy() for _ in range(5): N = nbr_mean(N) N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12) ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))) # signed offset from the locally-smooth surface: negative = groove, positive = ridge sm = co.copy() for _ in range(12): sm = nbr_mean(sm) dev = ((co - sm) * N).sum(axis=1) # metres, along the smooth normal torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90) for thr in (8.0, 12.0, 20.0): k = torso & (ang > thr) print(f"KINK >{thr:4.1f}deg : {k.sum():6d} verts " f"(groove depth p05={np.percentile(dev[k],5)*1000:+.3f} mm, " f"median={np.median(dev[k])*1000:+.3f} mm, " f"p95={np.percentile(dev[k],95)*1000:+.3f} mm)" if k.sum() else f"KINK >{thr}: none") kink = torso & (ang > 12.0) if kink.sum(): hist, edges = np.histogram(co[kink, 2], bins=16) print("KINK z histogram (the lines):") for c, lo, hi in zip(hist, edges[:-1], edges[1:]): if c > 20: print(f" z {lo:.3f}-{hi:.3f}: {c:5d}") # are kink verts topologically split? count how many sit on a boundary or have a # near-duplicate vertex that is NOT an edge-neighbour (= two panels touching, unmerged) from mathutils import Vector from mathutils.kdtree import KDTree kidx = np.nonzero(kink)[0] sample = kidx[::max(1, len(kidx) // 4000)] kd = KDTree(n_v) for i in range(n_v): kd.insert(Vector(co[i]), i) kd.balance() nbrs = [set() for _ in range(0)] adj = {} for a, b in ev: adj.setdefault(a, set()).add(b) adj.setdefault(b, set()).add(a) split = 0 for i in sample: for (_, j, d) in kd.find_range(Vector(co[i]), 0.0008): if j != i and j not in adj.get(i, ()): split += 1 break print(f"SPLIT-PANEL test on {len(sample)} kink verts: {split} " f"({100.0*split/max(len(sample),1):.1f}%) have an unmerged twin within 0.8 mm") # ============================================================================= # 3. CLEAVAGE — concavity of the medial corridor # ============================================================================= print("\n=== CLEAVAGE: medial corridor cross-sections y(x) ===") front = co[:, 1] < 0 for z0 in np.arange(0.62, 0.745, 0.015): row = [] for x0 in np.arange(-0.05, 0.0501, 0.005): m = front & (np.abs(co[:, 0] - x0) < 0.0035) & (np.abs(co[:, 2] - z0) < 0.004) row.append(co[m, 1].min() if m.sum() else np.nan) row = np.array(row) if np.isnan(row).all(): continue # curvature of the y(x) profile at the sternum: second difference over 5 mm steps mid = len(row) // 2 seg = row[max(0, mid - 3):mid + 4] if len(seg) >= 3 and not np.isnan(seg).any(): d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2) kmax = np.nanmax(d2) rad = 1.0 / kmax if kmax > 1e-6 else float('inf') print(f" z={z0:.3f} sternum y={row[mid]:+.4f} " f"max concave curvature {kmax:8.1f} 1/m -> fillet radius " f"{rad*1000:6.1f} mm" + (" <-- SHARP" if rad < 0.012 else "")) print("\n=== CLEAVAGE: depth of the notch (breast apex y vs sternum y) ===") for z0 in np.arange(0.62, 0.745, 0.015): ms = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.004) ma = front & (np.abs(np.abs(co[:, 0]) - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.004) if ms.sum() and ma.sum(): print(f" z={z0:.3f} sternum {co[ms,1].min():+.4f} apex {co[ma,1].min():+.4f} " f"notch {(co[ms,1].min()-co[ma,1].min())*1000:+6.1f} mm") # ============================================================================= # 2. DISCOLOURATION — repainted texels vs surrounding skin # ============================================================================= def grab_images(): out = {} for i in bpy.data.images: nm = i.name.lower() if "basecolor" in nm: out["base"] = i return out def px(img): w, h = img.size b = np.empty(w * h * 4, dtype=np.float32) img.pixels.foreach_get(b) return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h cur = grab_images() if "base" not in cur: print("\nDISCOLOUR: no basecolor image found; skipping") else: A, w, h = px(cur["base"]) log(f"current basecolor {w}x{h} '{cur['base'].name}'") np.save(os.path.join(SCRATCH, "cur_base.npy"), A) if ORIG and os.path.exists(ORIG): bpy.ops.wm.open_mainfile(filepath=ORIG) og = grab_images() if "base" in og: B, w2, h2 = px(og["base"]) log(f"original basecolor {w2}x{h2} '{og['base'].name}'") if (w2, h2) == (w, h): d = np.abs(A - B).max(axis=2) mask = d > 0.02 print(f"\nDISCOLOUR: repainted texels = {int(mask.sum())} " f"({100.0*mask.sum()/(w*h):.2f}% of atlas)") def dil(m, k): g = m.copy() for _ in range(k): n = g.copy() n[1:, :] |= g[:-1, :] n[:-1, :] |= g[1:, :] n[:, 1:] |= g[:, :-1] n[:, :-1] |= g[:, 1:] g = n return g inner = mask & ~dil(~mask, 6) # 6 px in from the patch edge ring = dil(mask, 10) & ~dil(mask, 2) # untouched skin just outside if inner.any() and ring.any(): mi = A[inner].mean(axis=0) mr = A[ring].mean(axis=0) print(f" patch interior mean RGB {mi[0]:.4f} {mi[1]:.4f} {mi[2]:.4f}") print(f" outside ring mean RGB {mr[0]:.4f} {mr[1]:.4f} {mr[2]:.4f}") print(f" OFFSET (patch-ring) {mi[0]-mr[0]:+.4f} {mi[1]-mr[1]:+.4f} " f"{mi[2]-mr[2]:+.4f} (luma {(mi.mean()-mr.mean()):+.4f})") print(f" patch interior stddev {A[inner].std(axis=0)}") print(f" ring stddev {A[ring].std(axis=0)}") # per-region: split the mask into connected blobs and report the big ones lab = np.zeros(mask.shape, dtype=np.int32) cur_l = 0 ys, xs = np.nonzero(mask) seen = np.zeros(mask.shape, dtype=bool) from collections import deque blobs = [] for y0, x0 in zip(ys, xs): if seen[y0, x0]: continue cur_l += 1 q = deque([(y0, x0)]) seen[y0, x0] = True cells = [] while q: y, x = q.popleft() cells.append((y, x)) for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): yy, xx = y + dy, x + dx if 0 <= yy < h and 0 <= xx < w and mask[yy, xx] and not seen[yy, xx]: seen[yy, xx] = True q.append((yy, xx)) if len(cells) > 2000: blobs.append(cells) print(f" {len(blobs)} patch blobs >2000 texels") for bi, cells in enumerate(sorted(blobs, key=len, reverse=True)[:8]): cy = np.array([c[0] for c in cells]) cx = np.array([c[1] for c in cells]) bm_ = np.zeros(mask.shape, dtype=bool) bm_[cy, cx] = True bin_ = bm_ & ~dil(~bm_, 5) br_ = dil(bm_, 10) & ~dil(bm_, 2) & ~mask if bin_.any() and br_.any(): a_ = A[bin_].mean(axis=0) r_ = A[br_].mean(axis=0) print(f" blob{bi}: {len(cells):7d} px uv~({cx.mean()/w:.3f}," f"{cy.mean()/h:.3f}) offset {a_[0]-r_[0]:+.4f} " f"{a_[1]-r_[1]:+.4f} {a_[2]-r_[2]:+.4f} luma " f"{a_.mean()-r_.mean():+.4f}") np.save(os.path.join(SCRATCH, "patch_mask.npy"), mask) log(f"saved patch_mask.npy ({int(mask.sum())} texels)") else: print(f"DISCOLOUR: size mismatch {w}x{h} vs {w2}x{h2}") print("DIAG_DONE")