← back to Quadrille Room Credit Enrich

enrich.py

316 lines

#!/usr/bin/env python3
"""Enrich thin/none-credit Quadrille worklist images (DTD verdict A, 3/3).
STRICT same-image provenance, NEVER fabricate. Richer credit is drawn ONLY from
the image's OWN filename (publication + issue/date + designer tokens that the
original extraction pass parsed poorly) and the image's OWN page-text. A separate
rich Product-Shot set exists but its images do NOT overlap the worklist, so
cross-attribution is refused.

Output cols: url, old_credit, new_credit, new_richness, + sub-fields, source."""
import json, re, csv

WL = "/Users/macstudio3/QuadrilleRoomSettings-needs-credit.csv"
OUT = "/Users/macstudio3/QuadrilleRoomSettings-credit-enriched.csv"
CAPS = "/Users/macstudio3/Projects/quadrille-room-credit-enrich/data/image_captions.json"

# publication token (underscored) -> display name. Longest-key-first matching.
PUBS = {
    'the_decorated_home': 'The Decorated Home', 'decorated_home': 'The Decorated Home',
    'atlanta_magazine': 'Atlanta Magazine', 'classic_home': 'Classic Home',
    'house_beautiful': 'House Beautiful', 'home_beautiful': 'House Beautiful',
    'coastal_living': 'Coastal Living', 'southern_living': 'Southern Living',
    'southern_accents': 'Southern Accents', 'traditional_home': 'Traditional Home',
    'flower_magazine': 'Flower Magazine', 'high_gloss_magazine': 'High Gloss Magazine',
    'metropolitan_home': 'Metropolitan Home', 'canadian_house_and_home': 'Canadian House and Home',
    'new_york_spaces': 'New York Spaces', 'at_home_fairfield_county': 'At Home Fairfield County',
    'veranda': 'Veranda', 'elle_decor': 'Elle Decor', 'town_and_country': 'Town & Country',
    'cottages_and_gardens': 'Cottages & Gardens', 'new_england_home': 'New England Home',
    'rue_magazine': 'Rue Magazine', 'vogue': 'Vogue', 'architectural_digest': 'Architectural Digest',
    'better_homes': 'Better Homes & Gardens', 'country_living': 'Country Living',
    'garden_and_gun': 'Garden & Gun', 'martha_stewart_living': 'Martha Stewart Living',
    'serendipity': 'Serendipity', 'dc_design_house': 'DC Design House',
    'pasadena_showhouse': 'Pasadena Showhouse', 'in_style': 'In Style',
    'luxe': 'Luxe', 'domino': 'Domino', 'milieu': 'Milieu',
}
PUB_KEYS = sorted(PUBS, key=len, reverse=True)

MONTHS = ('january february march april may june july august september october november december '
          'jan feb mar apr jun jul aug sep oct nov dec spring summer fall winter').split()
MONTH_ALT = '|'.join(MONTHS)
DATE_RE = re.compile(r'(?:(' + MONTH_ALT + r')_)?((?:19|20)\d{2})', re.I)

# room-context words to drop from a designer guess (NOT names)
ROOM_WORDS = set((
    'wallpaper paper fabric bed beds bedroom curtains curtain drapes drapery drape shades shade '
    'roman romans pillow pillows chair chairs sofa couch ottoman bench banquette stool stools '
    'headboard canopy tablecloth cushions cushion window seat bedskirt skirt valance reverse '
    'background print in and of new batik terrace showhouse dressing room hall foyer entry living '
    'dining wall walls panel panels small scale isle melong edo on cameron home grasscloth').split())


def fbase(url):
    fn = url.rsplit('/', 1)[-1].lower()
    fn = re.sub(r'_pc_\d+.*$|-pc-\d+.*$', '', fn)
    fn = re.sub(r'-sm-thumb|-thumb', '', fn)
    fn = re.sub(r'\.(jpe?g|png|gif|webp)$', '', fn)
    return fn.replace('-', '_')


def parse_filename(url, pattern):
    """Parse publication / date / designer from the image's OWN filename only."""
    fn = fbase(url)
    out = {}
    # 1) publication
    pub_key = None
    for k in PUB_KEYS:
        if k in fn:
            pub_key = k
            out['publication'] = PUBS[k]
            break
    # 2) date (month+year or year)
    dm = DATE_RE.search(fn)
    if dm:
        mo = (dm.group(1) or '').strip()
        yr = dm.group(2)
        out['date'] = (mo.title() + ' ' + yr).strip() if mo else yr
    # 3) designer = tokens between the pattern/room-context and the publication/date
    #    Take the slice BEFORE the publication key (or before the date if no pub),
    #    drop pattern tokens + room words, keep the remaining proper-name tokens.
    cut = len(fn)
    if pub_key:
        cut = min(cut, fn.find(pub_key))
    if dm and dm.start() < cut:
        cut = dm.start()
    head = fn[:cut].strip('_')
    toks = [t for t in head.split('_') if t]
    pat_toks = set(re.sub(r'[^a-z0-9 ]', ' ', pattern.lower()).split())
    name_toks = []
    for t in toks:
        if t in pat_toks or t in ROOM_WORDS or t.isdigit():
            continue
        name_toks.append(t)
    # only treat as a designer if we have 1-4 plausible name tokens
    if 1 <= len(name_toks) <= 5:
        designer = ' '.join(name_toks).title()
        # guard: drop if it's just a date/leftover
        if designer and not re.fullmatch(r'(?:' + MONTH_ALT + r')?\s*\d{4}', designer, re.I):
            out['designer'] = designer
    return out


def compose(designer, photographer, publication, date):
    seen = []
    if photographer:
        seen.append(f"Photography by {photographer}")
    if publication:
        seen.append(f"As Seen in {publication} {date}".rstrip() if date else f"As Seen in {publication}")
    elif date:
        seen.append(date)
    cred = designer or ""
    extra = "; ".join(seen)
    if cred and extra:
        return f"{cred} ({extra})"
    return cred or extra


def richness(designer, photographer, publication, date):
    if photographer and (publication or date):
        return "rich"
    if (publication or date) and designer:
        return "rich"
    if (photographer or publication or date):
        return "medium"
    if designer:
        return "thin"
    return "none"


# ---- same-image page-text (alt) designer index ----
_caps = json.load(open(CAPS))


def _normkey(u):
    s = u.rsplit('/', 1)[-1].lower()
    s = re.sub(r'-sm-thumb|-thumb', '', s)
    s = re.sub(r'\.\w+$', '', s)
    s = re.sub(r'_pc_\d+|-pc-\d+', '', s)
    return re.sub(r'[^a-z0-9]', '', s)


_alt_idx = {}
for _u, _v in _caps.items():
    _alt_idx.setdefault(_normkey(_u), []).append(_v)

# "by <Name> in/with/at" or "home of <Name>" — same-image authoritative designer
_BY_RE = re.compile(r'\b(?:by|home of)\s+([A-Z][A-Za-z\.\'\&\-]+(?:\s+[A-Z][A-Za-z\.\'\&\-]+){0,3})')


def alt_designer(url):
    cand = _alt_idx.get(_normkey(url), [])
    alt = max((c.get('alt', '') for c in cand), key=len) if cand else ''
    if not alt:
        return None
    m = _BY_RE.search(alt)
    if not m:
        return None
    name = m.group(1).strip()
    # trim trailing connective words the regex may capture
    name = re.sub(r'\s+(In|With|At|As|And|The|Of|Interiors?|Interior|Design)$', '', name).strip()
    if 2 <= len(name) <= 40 and ' ' in name:  # require a multi-word proper name
        return name
    return name if len(name) >= 4 else None


rows = list(csv.DictReader(open(WL)))

# All Quadrille pattern names (for stripping secondary-pattern tokens that leak
# into a filename-parsed designer, e.g. "Nitik Ii Phoebe Howard" -> "Phoebe Howard").
_ALL_PATTERNS = set()
for _r in rows:
    for _t in re.split(r'[^a-z0-9]+', _r['pattern'].lower()):
        if _t:
            _ALL_PATTERNS.add(_t)
_PUB_WORDS = set()
for _v in PUBS.values():
    for _t in re.split(r'[^a-z0-9]+', _v.lower()):
        if _t:
            _PUB_WORDS.add(_t)


def clean_designer(name, own_pattern):
    """Strip leaked secondary-pattern tokens and publication words from a
    filename-derived designer. Return None if nothing real remains."""
    if not name:
        return None
    own = set(re.split(r'[^a-z0-9]+', own_pattern.lower()))
    out = []
    for tok in name.split():
        low = tok.lower()
        # drop a leading pattern token that is NOT part of this row's own pattern
        if low in _ALL_PATTERNS and low not in own and not out:
            continue
        # drop publication words
        if low in _PUB_WORDS:
            continue
        out.append(tok)
    cleaned = ' '.join(out).strip()
    # if what's left is only publication-ish or a single ambiguous token, drop it
    if not cleaned or cleaned.lower() in {v.lower() for v in PUBS.values()}:
        return None
    return cleaned


out_rows = []
up_any = up_thin_rich = up_thin_medium = still = 0

for r in rows:
    url = r['room_image_url']
    pattern = r['pattern']
    old_credit = r['credit']
    old_rich = r['credit_richness']

    fp = parse_filename(url, pattern)
    designer = fp.get('designer')
    publication = fp.get('publication')
    date = fp.get('date')
    photographer = None  # no photographer ever present in worklist filenames or these page texts

    # AUTHORITATIVE designer = same-image alt-text "by <Name>" when present.
    # The page alt corrects filename typos (Christpher->Christopher) and
    # disambiguates secondary-pattern leaks (Aga Dana -> Dana Small).
    ad = alt_designer(url)
    if ad:
        designer = ad
    # drop a filename "designer" that is really the publication echoed back
    if designer and publication and designer.lower().replace('the ', '') == publication.lower().replace('the ', ''):
        designer = None
    # drop a filename "designer" that is just a known publication name
    if designer and designer.lower() in {v.lower() for v in PUBS.values()}:
        designer = None
    # if designer came from the FILENAME (no authoritative alt), scrub leaked
    # secondary-pattern + publication tokens; if alt gave it, trust it as-is
    if designer and not ad:
        designer = clean_designer(designer, pattern)
        # final guard: a filename "designer" whose every word is part of the
        # publication name (e.g. "Martha Stewart Living" == Martha Stewart Living,
        # "In Style" == In Style) is NOT an interior designer -> drop it.
        if designer and publication:
            dw = set(re.split(r'[^a-z0-9]+', designer.lower())) - {''}
            pw = set(re.split(r'[^a-z0-9]+', publication.lower())) - {''}
            if dw and dw <= pw:
                designer = None
        # a filename "designer" whose every remaining word is the row's OWN
        # pattern (e.g. "Sigourney Small Scale") is a pattern variant -> drop it.
        if designer:
            dw = set(re.split(r'[^a-z0-9]+', designer.lower())) - {''}
            ow = set(re.split(r'[^a-z0-9]+', pattern.lower())) - {''} | {'small', 'scale', 'reverse', 'background'}
            if dw and dw <= ow:
                designer = None

    # if filename yields NO new pub/date, fall back to the existing designer (don't downgrade)
    added = bool(publication or date)
    if not added:
        # keep whatever designer the original pass had (no change)
        designer = (r['designer'] or '').strip() or None
        new_rich = old_rich
        new_credit = old_credit
        src = 'filename'
        if new_rich in ('thin', 'none'):
            still += 1
    else:
        # if NEITHER the filename nor the alt yielded any designer signal at all,
        # keep the old one only if it's a real name (not a bare date). Do NOT
        # resurrect an old designer we deliberately scrubbed as a pub/pattern echo.
        had_signal = bool(fp.get('designer')) or bool(ad)
        if not designer and not had_signal:
            od = (r['designer'] or '').strip()
            if od and not re.fullmatch(r'(?:' + MONTH_ALT + r')?\s*\d{4}', od, re.I):
                designer = clean_designer(od, pattern)
        # apply pub/pattern-echo guards to whatever designer we ended with
        if designer and publication:
            dw = set(re.split(r'[^a-z0-9]+', designer.lower())) - {''}
            pw = set(re.split(r'[^a-z0-9]+', publication.lower())) - {''}
            if dw and dw <= pw:
                designer = None
        if designer:
            dw = set(re.split(r'[^a-z0-9]+', designer.lower())) - {''}
            ow = set(re.split(r'[^a-z0-9]+', pattern.lower())) - {''} | {'small', 'scale', 'reverse', 'background'}
            if dw and dw <= ow:
                designer = None
        new_rich = richness(designer, photographer, publication, date)
        new_credit = compose(designer, photographer, publication, date)
        src = 'filename-reparse'
        up_any += 1
        if old_rich in ('thin', 'none'):
            if new_rich == 'rich':
                up_thin_rich += 1
            elif new_rich == 'medium':
                up_thin_medium += 1

    out_rows.append({
        'url': url,
        'old_credit': old_credit,
        'new_credit': new_credit,
        'new_richness': new_rich,
        'new_designer': designer or '',
        'new_photographer': photographer or '',
        'new_publication': publication or '',
        'new_date': date or '',
        'source': src,
    })

with open(OUT, 'w', newline='') as f:
    w = csv.DictWriter(f, fieldnames=['url', 'old_credit', 'new_credit', 'new_richness',
                                      'new_designer', 'new_photographer', 'new_publication',
                                      'new_date', 'source'])
    w.writeheader()
    w.writerows(out_rows)

print(f"total rows: {len(out_rows)}")
print(f"rows upgraded (added pub/date): {up_any}")
print(f"  thin/none -> rich:   {up_thin_rich}")
print(f"  thin/none -> medium: {up_thin_medium}")
print(f"still thin/none (designer-only or empty): {still}")
print(f"source=filename-reparse rows: {sum(1 for r in out_rows if r['source']=='filename-reparse')}")
print(f"CSV -> {OUT}")