[object Object]

← back to Designer Wallcoverings

Add prw-92xxxx logo-banner crop tool (deterministic proportional crop + rollback)

3acf403725c1e6dabc18941c477a64c05256589b · 2026-06-17 09:54:56 -0700 · SteveStudio2

Removes the 'PHILLIPE ROMANO' header banner from active prw-92* product images.
Proportional crop (square 0.198h, 574x300 landscape 0.2733h) + banner-presence
confirmation; dry-validated across all 551 active (550 ok, 1 multi-image).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 3acf403725c1e6dabc18941c477a64c05256589b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed Jun 17 09:54:56 2026 -0700

    Add prw-92xxxx logo-banner crop tool (deterministic proportional crop + rollback)
    
    Removes the 'PHILLIPE ROMANO' header banner from active prw-92* product images.
    Proportional crop (square 0.198h, 574x300 landscape 0.2733h) + banner-presence
    confirmation; dry-validated across all 551 active (550 ok, 1 multi-image).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/prw-crop-work/.gitignore  |   2 +
 shopify/scripts/prw-logo-crop-rollback.py |  47 ++++++++++
 shopify/scripts/prw-logo-crop.py          | 149 ++++++++++++++++++++++++++++++
 3 files changed, 198 insertions(+)

diff --git a/shopify/scripts/prw-crop-work/.gitignore b/shopify/scripts/prw-crop-work/.gitignore
new file mode 100644
index 00000000..c6df0fee
--- /dev/null
+++ b/shopify/scripts/prw-crop-work/.gitignore
@@ -0,0 +1,2 @@
+originals/
+cropped/
diff --git a/shopify/scripts/prw-logo-crop-rollback.py b/shopify/scripts/prw-logo-crop-rollback.py
new file mode 100644
index 00000000..a7c78a10
--- /dev/null
+++ b/shopify/scripts/prw-logo-crop-rollback.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""Revert prw-logo-crop.py: re-upload each product's original (banner) image from
+the local backup (prw-crop-work/originals/<pid>.jpg) and delete the cropped image.
+Reads prw-crop-work/rollback.json. Idempotent-ish; safe to re-run.
+
+Usage:  SHOPIFY_ADMIN_TOKEN=... python3 prw-logo-crop-rollback.py --execute
+        (no --execute = dry list of what would be reverted)
+"""
+import os, sys, json, base64, time, urllib.request, urllib.error
+
+SHOP = "designer-laboratory-sandbox.myshopify.com"
+TOK  = os.environ.get("SHOPIFY_ADMIN_TOKEN") or sys.exit("Missing SHOPIFY_ADMIN_TOKEN")
+API  = f"https://{SHOP}/admin/api/2024-10"
+HERE = os.path.dirname(os.path.abspath(__file__))
+WORK = os.path.join(HERE, "prw-crop-work"); ORIG = os.path.join(WORK, "originals")
+EXECUTE = "--execute" in sys.argv
+
+def http(method, url, data=None, tries=5):
+    for a in range(tries):
+        try:
+            req = urllib.request.Request(url, data=data, method=method,
+                headers={"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"})
+            with urllib.request.urlopen(req) as r: return json.loads(r.read())
+        except urllib.error.HTTPError as e:
+            if e.code in (429,500,502,503,504) and a < tries-1:
+                time.sleep(float(e.headers.get("Retry-After",2))+a); continue
+            raise
+
+def numeric(g): return str(g).rsplit("/",1)[-1]
+
+rb = json.load(open(os.path.join(WORK,"rollback.json")))
+print(f"{len(rb)} products to revert" + ("" if EXECUTE else " (dry — pass --execute)"))
+for i, r in enumerate(rb, 1):
+    pid, handle = r["pid"], r["handle"]
+    op = os.path.join(ORIG, f"{pid}.jpg")
+    if not os.path.exists(op):
+        print(f"[{i}] {handle}  MISSING original backup -> skip"); continue
+    if not EXECUTE:
+        print(f"[{i}] {handle}  would restore original, delete new img {r['new_img_id']}"); continue
+    b64 = base64.b64encode(open(op,"rb").read()).decode()
+    up = http("POST", f"{API}/products/{pid}/images.json",
+              data=json.dumps({"image":{"attachment":b64,"filename":f"{handle}.jpg","position":1}}).encode())
+    time.sleep(0.6)
+    http("DELETE", f"{API}/products/{pid}/images/{numeric(r['new_img_id'])}.json")
+    print(f"[{i}] {handle}  restored original (new img {up['image']['id']}), deleted crop")
+    time.sleep(0.6)
+print("done.")
diff --git a/shopify/scripts/prw-logo-crop.py b/shopify/scripts/prw-logo-crop.py
new file mode 100644
index 00000000..284ed251
--- /dev/null
+++ b/shopify/scripts/prw-logo-crop.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Crop the 'PHILLIPE ROMANO' header-logo banner off active prw-92xxxx product images.
+
+The vendor template puts a near-black banner (logo + 'WALLPAPER - FABRICS - LOS ANGELES'
++ a stock code) across the top of every prw-92* product photo. This auto-detects the
+band's bottom edge per-image (proportional, so it tolerates size variation) and crops it.
+
+Phases:
+  --dry      download originals -> detect band -> crop locally. No Shopify writes. (default)
+  --execute  also upload the cropped image (position 1) + delete the original banner image.
+
+Rollback: originals are backed up locally (work/originals/<pid>.jpg) AND their Shopify
+CDN urls recorded, so any product can be reverted. See prw-logo-crop-rollback.py.
+
+Cost: $0 (local crop; Shopify Admin API writes are free).
+"""
+import os, sys, json, base64, time, urllib.request, urllib.error
+from io import BytesIO
+from PIL import Image
+
+SHOP = "designer-laboratory-sandbox.myshopify.com"
+TOK  = os.environ.get("SHOPIFY_ADMIN_TOKEN")
+if not TOK:
+    sys.exit("Missing SHOPIFY_ADMIN_TOKEN (source ~/Projects/secrets-manager/.env)")
+API = f"https://{SHOP}/admin/api/2024-10"
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+WORK = os.path.join(HERE, "prw-crop-work")
+ORIG = os.path.join(WORK, "originals"); CROP = os.path.join(WORK, "cropped")
+for d in (WORK, ORIG, CROP): os.makedirs(d, exist_ok=True)
+
+EXECUTE = "--execute" in sys.argv
+LIST = "/tmp/prw92_active.json"
+
+def http(method, url, data=None, headers=None, raw=False, tries=5):
+    hdr = headers or {"X-Shopify-Access-Token": TOK, "Content-Type": "application/json"}
+    for attempt in range(tries):
+        try:
+            req = urllib.request.Request(url, data=data, method=method, headers=hdr)
+            with urllib.request.urlopen(req) as r:
+                b = r.read()
+                return b if raw else json.loads(b)
+        except urllib.error.HTTPError as e:
+            if e.code in (429, 500, 502, 503, 504) and attempt < tries - 1:
+                wait = float(e.headers.get("Retry-After", 2)) + attempt
+                print(f"    {e.code} on {method} {url[-40:]} -> retry in {wait:.0f}s")
+                time.sleep(wait); continue
+            raise
+
+def detect_crop_y(img):
+    """Deterministic proportional crop. The 'PHILLIPE ROMANO' banner is a fixed
+    vendor template at a constant fraction of image height:
+      - square / portrait template (900x900, 350x350, 752x752, 4173x4173): 0.198 h
+      - 574x300 landscape sub-template (Innovations): 0.2733 h  (=82px)
+    Darkness-walk detection over-cropped dark patterns and under-detected the
+    landscape banner, so we use the fixed proportion and only *confirm* a banner
+    is actually present (mostly-dark bar containing the bright white logo text)
+    before trusting the crop. Returns (crop_y, note); note 'ok' or 'flag-...'.
+    """
+    g = img.convert("L"); w, h = g.size; px = g.load()
+    aspect = w / h
+    crop_y = round(h * 0.2733) if aspect > 1.1 else round(h * 0.198)
+    if crop_y < 2:
+        return None, "tiny"
+    dark = bright = n = 0
+    for y in range(0, crop_y, 2):
+        for x in range(0, w, 3):
+            v = px[x, y]; n += 1
+            if v < 40: dark += 1
+            elif v > 200: bright += 1
+    darkf = dark / n; brightf = bright / n
+    # banner = mostly-dark bar (darkf high) carrying bright white logo text (brightf).
+    # Calibrated: real banners square darkf~0.80/brightf~0.05, landscape ~0.60/~0.04.
+    if darkf >= 0.45 and brightf >= 0.015:
+        return crop_y, "ok"
+    return crop_y, f"flag-nobanner(d={darkf:.2f},b={brightf:.3f})"
+
+def numeric(gid): return gid.rsplit("/", 1)[-1]
+
+ROLLBACK = os.path.join(WORK, "rollback.json")
+
+def main():
+    rows = json.load(open(LIST))
+    manifest = []
+    counts = {"ok": 0, "no-band": 0, "flag": 0, "multi-image": 0, "uploaded": 0, "skip-done": 0, "error": 0}
+    rollback = json.load(open(ROLLBACK)) if os.path.exists(ROLLBACK) else []
+    done = {r["pid"] for r in rollback}        # resumability: already-uploaded pids
+    for i, r in enumerate(rows, 1):
+        pid = numeric(r["id"]); handle = r["handle"]; imgs = r["images"]
+        rec = {"pid": pid, "handle": handle, "old_images": imgs}
+        if EXECUTE and pid in done:
+            rec["status"] = "skip-done"; counts["skip-done"] += 1
+            manifest.append(rec); continue
+        if len(imgs) != 1:
+            rec["status"] = "multi-image"; counts["multi-image"] += 1
+            manifest.append(rec)
+            print(f"[{i}/{len(rows)}] {handle}  MULTI-IMAGE ({len(imgs)}) -> flagged, skipped")
+            continue
+        old_img_id, old_url = imgs[0]
+        try:
+            orig_path = os.path.join(ORIG, f"{pid}.jpg")
+            if os.path.exists(orig_path):
+                raw = open(orig_path, "rb").read()           # reuse dry-run download
+            else:
+                raw = http("GET", old_url, headers={"User-Agent": "Mozilla/5.0"}, raw=True)
+                open(orig_path, "wb").write(raw)             # cache + rollback backup (ALL)
+            img = Image.open(BytesIO(raw))
+            crop_y, note = detect_crop_y(img)
+            rec["crop_y"] = crop_y; rec["note"] = note
+            if note.startswith("no-band"):
+                rec["status"] = "no-band"; counts["no-band"] += 1
+                print(f"[{i}/{len(rows)}] {handle}  {note} -> skip")
+                manifest.append(rec); continue
+            if note.startswith("flag"):
+                rec["status"] = "flag"; counts["flag"] += 1
+                print(f"[{i}/{len(rows)}] {handle}  {note} -> FLAG for review")
+                manifest.append(rec); continue
+            # ok: crop (original already cached above for rollback)
+            cropped = img.crop((0, crop_y, img.size[0], img.size[1]))
+            cpath = os.path.join(CROP, f"{pid}.jpg")
+            cropped.convert("RGB").save(cpath, quality=92)
+            rec["status"] = "ok"; rec["crop_size"] = cropped.size; counts["ok"] += 1
+            if EXECUTE:
+                b64 = base64.b64encode(open(cpath, "rb").read()).decode()
+                up = http("POST", f"{API}/products/{pid}/images.json",
+                          data=json.dumps({"image": {"attachment": b64,
+                              "filename": f"{handle}.jpg", "position": 1}}).encode())
+                new_id = up["image"]["id"]; rec["new_img_id"] = new_id
+                time.sleep(0.6)
+                http("DELETE", f"{API}/products/{pid}/images/{numeric(old_img_id)}.json")
+                counts["uploaded"] += 1
+                # persist rollback record immediately (crash-safe / resumable)
+                rollback.append({"pid": pid, "handle": handle, "old_img_id": old_img_id,
+                                 "old_url": old_url, "new_img_id": new_id, "crop_y": crop_y})
+                json.dump(rollback, open(ROLLBACK, "w"), indent=1)
+                print(f"[{i}/{len(rows)}] {handle}  cropped@{crop_y} -> UPLOADED new img {new_id}, deleted old")
+                time.sleep(0.6)
+            else:
+                print(f"[{i}/{len(rows)}] {handle}  cropped@{crop_y} -> {cropped.size} (dry)")
+        except Exception as e:
+            rec["status"] = "error"; rec["error"] = str(e); counts["error"] += 1
+            print(f"[{i}/{len(rows)}] {handle}  ERROR {e}")
+        manifest.append(rec)
+    json.dump(manifest, open(os.path.join(WORK, "manifest.json"), "w"), indent=1)
+    print("\n=== SUMMARY ===", json.dumps(counts))
+    print("manifest:", os.path.join(WORK, "manifest.json"))
+
+if __name__ == "__main__":
+    main()

← 28c70316 GMC enrich: Rebel Walls specs gap-fill drafts (234 prod, 252  ·  back to Designer Wallcoverings  ·  source-gate: reject boolean/NULL tokens as MFR SKU in dwjs-m 4fa11471 →