← 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}")