"""
Artist analysis module for renoir.
This module provides functions for extracting and analyzing artist-specific works
from the WikiArt dataset, designed for educational use in computational design
and digital humanities courses.
"""
from typing import List, Dict, Optional, Any, Tuple, TYPE_CHECKING
from collections import Counter, defaultdict
from datasets import load_dataset
import numpy as np
if TYPE_CHECKING:
from renoir.color import ColorAnalyzer, ColorNamer
[docs]
class ArtistAnalyzer:
"""
Analyze artist-specific works from the WikiArt dataset.
This class provides methods to extract works by specific artists and analyze
their metadata (genres, styles, periods). Designed for teaching data analysis
to art and design students.
Examples:
>>> analyzer = ArtistAnalyzer()
>>> works = analyzer.extract_artist_works('claude-monet')
>>> print(f"Found {len(works)} works by Monet")
>>> genres = analyzer.analyze_genres(works)
>>> print(f"Main genre: {genres[0]}")
"""
def __init__(self, cache_dir: Optional[str] = None):
"""
Initialize the ArtistAnalyzer.
Args:
cache_dir: Optional directory to cache the WikiArt dataset
"""
self.cache_dir = cache_dir
self._dataset = None
def _load_dataset(self):
"""
Lazy load the WikiArt dataset.
Raises:
RuntimeError: If dataset loading fails
"""
if self._dataset is None:
try:
print("Loading WikiArt dataset...")
self._dataset = load_dataset(
"huggan/wikiart", split="train", cache_dir=self.cache_dir
)
print(f"✓ Loaded {len(self._dataset)} artworks")
except Exception as e:
raise RuntimeError(
f"Failed to load WikiArt dataset. "
f"Please check your internet connection and try again. "
f"Error: {str(e)}"
)
return self._dataset
[docs]
def load_dataset(self):
"""
Load and return the WikiArt dataset.
This is a public alias for :meth:`_load_dataset`.
Returns:
The loaded WikiArt dataset.
Raises:
RuntimeError: If dataset loading fails
"""
return self._load_dataset()
[docs]
def list_artists(self, limit: Optional[int] = None) -> List[str]:
"""
List artist names available in the WikiArt dataset.
Args:
limit: Optional maximum number of artist names to return
Returns:
List of artist names as they appear in the dataset
"""
dataset = self._load_dataset()
if not hasattr(dataset, "features"):
return []
artist_feature = dataset.features.get("artist")
if artist_feature is None or not hasattr(artist_feature, "names"):
return []
names = list(artist_feature.names)
if limit is not None:
names = names[:limit]
return names
[docs]
def analyze_genres(self, works: List[Dict[str, Any]]) -> List[tuple]:
"""
Analyze genre distribution in a collection of works.
Args:
works: List of artwork dictionaries
Returns:
List of (genre, count) tuples, sorted by frequency
Raises:
ValueError: If works is not a list
TypeError: If works contains non-dict elements
Examples:
>>> works = analyzer.extract_artist_works('claude-monet')
>>> genres = analyzer.analyze_genres(works)
>>> print(f"Most common genre: {genres[0][0]} ({genres[0][1]} works)")
"""
if not isinstance(works, list):
raise ValueError("works must be a list")
if not works:
return []
# Validate that all items are dictionaries
for i, work in enumerate(works):
if not isinstance(work, dict):
raise TypeError(f"Item at index {i} is not a dictionary")
genres = [work.get("genre", "Unknown") for work in works]
genre_counts = Counter(genres).most_common()
return genre_counts
[docs]
def analyze_styles(self, works: List[Dict[str, Any]]) -> List[tuple]:
"""
Analyze style distribution in a collection of works.
Args:
works: List of artwork dictionaries
Returns:
List of (style, count) tuples, sorted by frequency
Raises:
ValueError: If works is not a list
TypeError: If works contains non-dict elements
Examples:
>>> works = analyzer.extract_artist_works('pablo-picasso')
>>> styles = analyzer.analyze_styles(works)
>>> for style, count in styles[:3]:
... print(f"{style}: {count} works")
"""
if not isinstance(works, list):
raise ValueError("works must be a list")
if not works:
return []
# Validate that all items are dictionaries
for i, work in enumerate(works):
if not isinstance(work, dict):
raise TypeError(f"Item at index {i} is not a dictionary")
styles = [work.get("style", "Unknown") for work in works]
style_counts = Counter(styles).most_common()
return style_counts
[docs]
def analyze_temporal_distribution(
self, works: List[Dict[str, Any]]
) -> Dict[str, int]:
"""
Analyze the temporal distribution of works by decade.
Args:
works: List of artwork dictionaries
Returns:
Dictionary mapping decades to work counts
Examples:
>>> works = analyzer.extract_artist_works('vincent-van-gogh')
>>> decades = analyzer.analyze_temporal_distribution(works)
>>> for decade, count in sorted(decades.items()):
... print(f"{decade}s: {count} works")
"""
decades = {}
for work in works:
date = work.get("date")
if date and isinstance(date, (int, str)):
try:
year = int(str(date)[:4]) if isinstance(date, str) else date
decade = (year // 10) * 10
decades[decade] = decades.get(decade, 0) + 1
except (ValueError, IndexError):
pass
return decades
[docs]
def get_work_summary(self, works: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Generate a comprehensive summary of a collection of works.
Args:
works: List of artwork dictionaries
Returns:
Dictionary with summary statistics
Examples:
>>> works = analyzer.extract_artist_works('edvard-munch')
>>> summary = analyzer.get_work_summary(works)
>>> print(f"Total works: {summary['total_works']}")
>>> print(f"Main style: {summary['primary_style']}")
"""
if not works:
return {
"total_works": 0,
"artist": None,
"primary_style": None,
"primary_genre": None,
"date_range": None,
}
genres = self.analyze_genres(works)
styles = self.analyze_styles(works)
# Extract date range
dates = []
for work in works:
date = work.get("date")
if date:
try:
year = int(str(date)[:4]) if isinstance(date, str) else date
dates.append(year)
except (ValueError, IndexError):
pass
date_range = None
if dates:
date_range = (min(dates), max(dates))
return {
"total_works": len(works),
"artist": works[0].get("artist", "Unknown"),
"primary_style": styles[0][0] if styles else None,
"primary_genre": genres[0][0] if genres else None,
"date_range": date_range,
"all_genres": genres,
"all_styles": styles,
}
def _check_visualization_available(self) -> bool:
"""
Check if visualization libraries are available.
Returns:
bool: True if matplotlib and seaborn are installed
"""
try:
import matplotlib.pyplot as plt
import seaborn as sns
return True
except ImportError:
return False
[docs]
def plot_genre_distribution(
self,
artist_name: str,
limit: Optional[int] = None,
save_path: Optional[str] = None,
figsize: tuple = (10, 6),
show: bool = True,
) -> Any:
"""
Plot genre distribution for a specific artist as a bar chart.
Args:
artist_name: Artist name as it appears in WikiArt
limit: Optional limit on number of works to analyze
save_path: Optional path to save the figure
figsize: Figure size as (width, height)
show: If True, display the figure with plt.show()
Example:
>>> analyzer = ArtistAnalyzer()
>>> fig = analyzer.plot_genre_distribution('pierre-auguste-renoir')
"""
if not self._check_visualization_available():
print("Visualization libraries not available.")
print("Install with: pip install 'renoir-wikiart[visualization]'")
return None
import matplotlib.pyplot as plt
import seaborn as sns
# Extract and analyze works
works = self.extract_artist_works(artist_name, limit=limit)
genres = self.analyze_genres(works)
if not genres:
print(f"No genre data available for {artist_name}")
return
# Prepare data for plotting
genre_names = [g[0] for g in genres]
genre_counts = [g[1] for g in genres]
# Create plot
fig, ax = plt.subplots(figsize=figsize)
sns.barplot(x=genre_counts, y=genre_names, ax=ax, palette="viridis")
ax.set_xlabel("Number of Works", fontsize=12)
ax.set_ylabel("Genre", fontsize=12)
ax.set_title(
f"Genre Distribution: {artist_name}", fontsize=14, fontweight="bold"
)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Figure saved to {save_path}")
if show:
plt.show()
return fig
[docs]
def plot_style_distribution(
self,
artist_name: str,
limit: Optional[int] = None,
save_path: Optional[str] = None,
figsize: tuple = (10, 6),
show: bool = True,
) -> Any:
"""
Plot style distribution for a specific artist as a bar chart.
Args:
artist_name: Artist name as it appears in WikiArt
limit: Optional limit on number of works to analyze
save_path: Optional path to save the figure
figsize: Figure size as (width, height)
show: If True, display the figure with plt.show()
Example:
>>> analyzer = ArtistAnalyzer()
>>> fig = analyzer.plot_style_distribution('pablo-picasso')
"""
if not self._check_visualization_available():
print("Visualization libraries not available.")
print("Install with: pip install 'renoir-wikiart[visualization]'")
return None
import matplotlib.pyplot as plt
import seaborn as sns
# Extract and analyze works
works = self.extract_artist_works(artist_name, limit=limit)
styles = self.analyze_styles(works)
if not styles:
print(f"No style data available for {artist_name}")
return
# Prepare data for plotting
style_names = [s[0] for s in styles]
style_counts = [s[1] for s in styles]
# Create plot
fig, ax = plt.subplots(figsize=figsize)
sns.barplot(x=style_counts, y=style_names, ax=ax, palette="mako")
ax.set_xlabel("Number of Works", fontsize=12)
ax.set_ylabel("Style", fontsize=12)
ax.set_title(
f"Style Distribution: {artist_name}", fontsize=14, fontweight="bold"
)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Figure saved to {save_path}")
if show:
plt.show()
return fig
[docs]
def compare_artists_genres(
self,
artist_names: List[str],
limit: Optional[int] = None,
save_path: Optional[str] = None,
figsize: tuple = (12, 8),
show: bool = True,
) -> Any:
"""
Compare genre distributions across multiple artists.
Args:
artist_names: List of artist names to compare
limit: Optional limit on number of works per artist
save_path: Optional path to save the figure
figsize: Figure size as (width, height)
show: If True, display the figure with plt.show()
Example:
>>> analyzer = ArtistAnalyzer()
>>> fig = analyzer.compare_artists_genres(['claude-monet', 'pierre-auguste-renoir', 'edgar-degas'])
"""
if not self._check_visualization_available():
print("Visualization libraries not available.")
print("Install with: pip install 'renoir-wikiart[visualization]'")
return None
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
# Collect data for all artists
all_data = []
for artist_name in artist_names:
works = self.extract_artist_works(artist_name, limit=limit)
genres = self.analyze_genres(works)
for genre, count in genres:
all_data.append({"Artist": artist_name, "Genre": genre, "Count": count})
if not all_data:
print("No data available for comparison")
return
# Create DataFrame for easier plotting
df = pd.DataFrame(all_data)
# Create grouped bar chart
fig, ax = plt.subplots(figsize=figsize)
# Pivot data for grouped bars
pivot_df = df.pivot(index="Genre", columns="Artist", values="Count").fillna(0)
pivot_df.plot(kind="bar", ax=ax, width=0.8)
ax.set_xlabel("Genre", fontsize=12)
ax.set_ylabel("Number of Works", fontsize=12)
ax.set_title("Genre Distribution Comparison", fontsize=14, fontweight="bold")
ax.legend(title="Artist", bbox_to_anchor=(1.05, 1), loc="upper left")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Figure saved to {save_path}")
elif show:
plt.show()
return fig
[docs]
def create_artist_overview(
self,
artist_name: str,
limit: Optional[int] = None,
save_path: Optional[str] = None,
figsize: tuple = (14, 10),
show: bool = True,
) -> Any:
"""
Create a comprehensive overview visualization for an artist.
Includes genre distribution, style distribution, and temporal analysis
in a multi-panel figure.
Args:
artist_name: Artist name as it appears in WikiArt
limit: Optional limit on number of works to analyze
save_path: Optional path to save the figure
figsize: Figure size as (width, height)
show: If True, display the figure with plt.show()
Example:
>>> analyzer = ArtistAnalyzer()
>>> fig = analyzer.create_artist_overview('vincent-van-gogh')
"""
if not self._check_visualization_available():
print("Visualization libraries not available.")
print("Install with: pip install 'renoir-wikiart[visualization]'")
return None
import matplotlib.pyplot as plt
import seaborn as sns
# Extract and analyze works
works = self.extract_artist_works(artist_name, limit=limit)
genres = self.analyze_genres(works)
styles = self.analyze_styles(works)
temporal = self.analyze_temporal_distribution(works)
summary = self.get_work_summary(works)
# Create figure with subplots
fig = plt.figure(figsize=figsize)
gs = fig.add_gridspec(3, 2, hspace=0.3, wspace=0.3)
# Title
fig.suptitle(
f"Artist Overview: {artist_name}", fontsize=16, fontweight="bold", y=0.98
)
# Summary text
ax_summary = fig.add_subplot(gs[0, :])
ax_summary.axis("off")
summary_text = f"""
Total Works: {summary['total_works']}
Primary Style: {summary['primary_style']}
Primary Genre: {summary['primary_genre']}
Date Range: {summary['date_range'][0]}-{summary['date_range'][1] if summary['date_range'] else 'N/A'}
"""
ax_summary.text(
0.5,
0.5,
summary_text,
ha="center",
va="center",
fontsize=12,
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.3),
)
# Genre distribution
if genres:
ax_genre = fig.add_subplot(gs[1, 0])
genre_names = [g[0] for g in genres[:10]] # Top 10
genre_counts = [g[1] for g in genres[:10]]
sns.barplot(x=genre_counts, y=genre_names, ax=ax_genre, palette="viridis")
ax_genre.set_xlabel("Count")
ax_genre.set_ylabel("Genre")
ax_genre.set_title("Genre Distribution", fontweight="bold")
# Style distribution
if styles:
ax_style = fig.add_subplot(gs[1, 1])
style_names = [s[0] for s in styles[:10]] # Top 10
style_counts = [s[1] for s in styles[:10]]
sns.barplot(x=style_counts, y=style_names, ax=ax_style, palette="mako")
ax_style.set_xlabel("Count")
ax_style.set_ylabel("Style")
ax_style.set_title("Style Distribution", fontweight="bold")
# Temporal distribution
if temporal:
ax_temporal = fig.add_subplot(gs[2, :])
decades = sorted(temporal.keys())
counts = [temporal[d] for d in decades]
ax_temporal.plot(decades, counts, marker="o", linewidth=2, markersize=8)
ax_temporal.fill_between(decades, counts, alpha=0.3)
ax_temporal.set_xlabel("Decade")
ax_temporal.set_ylabel("Number of Works")
ax_temporal.set_title("Temporal Distribution", fontweight="bold")
ax_temporal.grid(True, alpha=0.3)
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Figure saved to {save_path}")
if show:
plt.show()
return fig
# ------------------------------------------------------------------
# Portfolio Color Signature API (Phase 5)
# ------------------------------------------------------------------
def _parse_year(self, work: Dict[str, Any]) -> Optional[int]:
"""
Extract a four-digit year from an artwork dict.
Mirrors the parsing logic in :meth:`analyze_temporal_distribution`
so that synthetic and augmented datasets with date fields work
consistently with the rest of the package.
"""
date = work.get("date")
if date and isinstance(date, (int, str)):
try:
return int(str(date)[:4]) if isinstance(date, str) else date
except (ValueError, IndexError):
pass
return None
def _sample_works(
self,
works: List[Dict[str, Any]],
limit: int,
strategy: str,
random_state: int,
) -> Tuple[List[Dict[str, Any]], str]:
"""
Select up to ``limit`` works using the requested strategy.
Temporal sampling bins works by decade and distributes selections
evenly across bins. If no parseable dates are found, it falls back
to random sampling and reports the effective strategy.
"""
if strategy not in {"temporal", "random", "first"}:
raise ValueError("strategy must be 'temporal', 'random', or 'first'")
if strategy == "first":
return works[:limit], "first"
if strategy == "random":
rng = np.random.RandomState(random_state)
n = min(limit, len(works))
indices = rng.choice(len(works), n, replace=False)
return [works[i] for i in sorted(indices)], "random"
# strategy == "temporal"
dated = []
for work in works:
year = self._parse_year(work)
if year is not None:
decade = (year // 10) * 10
dated.append((work, year, decade))
if not dated:
sampled, _ = self._sample_works(works, limit, "random", random_state)
return sampled, "random"
decade_groups = defaultdict(list)
for work, year, decade in dated:
decade_groups[decade].append((work, year))
decades = sorted(decade_groups.keys())
n_decades = len(decades)
# Distribute limit across decades as evenly as possible
base = limit // n_decades
extra = limit % n_decades
rng = np.random.RandomState(random_state)
selected = []
leftovers = []
for i, decade in enumerate(decades):
n_from_decade = base + (1 if i < extra else 0)
candidates = decade_groups[decade]
candidates.sort(key=lambda x: x[1])
if len(candidates) <= n_from_decade:
chosen = candidates
else:
indices = set(rng.choice(len(candidates), n_from_decade, replace=False))
chosen = [candidates[idx] for idx in sorted(indices)]
leftovers.extend(
candidates[idx]
for idx in range(len(candidates))
if idx not in indices
)
selected.extend([work for work, _ in chosen])
# A decade with fewer candidates than its quota causes an undershoot;
# redistribute the shortfall from decades that still have leftovers.
shortfall = limit - len(selected)
if shortfall > 0 and leftovers:
n_extra = min(shortfall, len(leftovers))
indices = rng.choice(len(leftovers), n_extra, replace=False)
selected.extend(leftovers[idx][0] for idx in sorted(indices))
return selected, "temporal"
def _aggregate_palette(
self,
colors: List[Tuple[int, int, int]],
n_colors: int,
random_state: int,
namer: Optional["ColorNamer"] = None,
) -> List[Tuple[int, int, int]]:
"""
Aggregate a collection of colors into ``n_colors`` representatives.
Clustering is performed in CIE Lab space to keep the aggregation
perceptually meaningful, then cluster centers are converted back
to sRGB. If the input contains fewer unique colors than requested,
the unique colors are returned instead of forcing duplicate clusters.
"""
if not colors:
return []
# Normalize to plain-Python int tuples
colors = [tuple(int(c) for c in color) for color in colors]
n_colors = min(n_colors, len(colors))
if n_colors <= 0:
return []
if namer is None:
from renoir.color import ColorNamer
namer = ColorNamer()
unique_colors = list(dict.fromkeys(colors)) # preserve order, deduplicate
if n_colors >= len(unique_colors):
return unique_colors
labs = np.array([namer._rgb_to_lab(c) for c in colors])
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=n_colors, random_state=random_state, n_init=10)
kmeans.fit(labs)
centers = kmeans.cluster_centers_
aggregated = [namer._lab_to_rgb(tuple(center)) for center in centers]
return aggregated
def _compute_palette_metrics(
self,
palette: List[Tuple[int, int, int]],
analyzer: "ColorAnalyzer",
) -> Dict[str, Any]:
"""Compute the standard color metrics for a palette."""
if not palette:
return {}
return {
"diversity": analyzer.calculate_color_diversity(palette),
"saturation": analyzer.calculate_saturation_score(palette),
"brightness": analyzer.calculate_brightness_score(palette),
"temperature": analyzer.analyze_color_temperature_distribution(palette),
"harmony": analyzer.analyze_color_harmony(palette),
"complexity": analyzer.calculate_color_complexity(palette),
}
def _empty_signature(self, artist_name: Optional[str] = None) -> Dict[str, Any]:
"""Return a neutral signature result when no works can be analyzed."""
return {
"artist": artist_name,
"n_works_analyzed": 0,
"n_works_available": 0,
"n_works_selected": 0,
"requested_strategy": None,
"effective_strategy": None,
"date_range": None,
"palette": [],
"metrics": {},
"by_period": {},
"work_palettes": [],
"figure": None,
}
def _build_signature_figure(
self,
palette: List[Tuple[int, int, int]],
artist_name: Optional[str],
by_period: Dict[str, Any],
save_path: Optional[str],
show: bool = False,
) -> Any:
"""Build a multi-panel overview figure for a color signature."""
if not self._check_visualization_available():
print("Visualization libraries not available.")
print("Install with: pip install 'renoir-wikiart[visualization]'")
return None
from renoir.color import ColorVisualizer
import matplotlib.pyplot as plt
import matplotlib.patches as patches
title = artist_name if artist_name else "Artist"
if by_period:
fig = plt.figure(figsize=(14, 8))
gs = fig.add_gridspec(2, 1, hspace=0.3)
ax_palette = fig.add_subplot(gs[0])
for i, color in enumerate(palette):
normalized = tuple(c / 255 for c in color)
rect = patches.Rectangle(
(i, 0), 1, 1, facecolor=normalized, edgecolor="black", linewidth=2
)
ax_palette.add_patch(rect)
ax_palette.set_xlim(0, len(palette))
ax_palette.set_ylim(0, 1)
ax_palette.set_aspect("equal")
ax_palette.axis("off")
ax_palette.set_title(
f"Color Signature: {title} — Aggregated Palette",
fontsize=14,
fontweight="bold",
)
ax_trend = fig.add_subplot(gs[1])
periods = sorted(by_period.keys(), key=lambda p: int(p))
complexities = [
by_period[p]["metrics"].get("complexity", {}).get("cci", 0)
for p in periods
]
saturations = [
by_period[p]["metrics"].get("saturation", 0) for p in periods
]
ax_trend.plot(periods, complexities, marker="o", label="CCI", linewidth=2)
ax_trend.plot(
periods, saturations, marker="s", label="Saturation", linewidth=2
)
ax_trend.set_xlabel("Period", fontsize=12)
ax_trend.set_ylabel("Score", fontsize=12)
ax_trend.set_title("Temporal Evolution", fontsize=14, fontweight="bold")
ax_trend.legend()
ax_trend.grid(True, alpha=0.3)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
print(f"Figure saved to {save_path}")
if show:
plt.show()
return fig
visualizer = ColorVisualizer()
return visualizer.create_artist_color_report(
palette, title, save_path=save_path, show=show
)
[docs]
def analyze_works_color_signature(
self,
works: List[Dict[str, Any]],
n_colors: int = 5,
group_by_period: bool = True,
include_figure: bool = False,
save_path: Optional[str] = None,
random_state: int = 42,
verbose: bool = True,
) -> Dict[str, Any]:
"""
Compute a color signature from a provided list of artwork dictionaries.
This lower-level method lets callers supply their own dated works,
enabling full temporal analysis when the dataset includes date metadata.
Args:
works: List of artwork dictionaries. Each should contain an ``image``
key (PIL Image or ndarray) and optionally a ``date`` key.
n_colors: Number of dominant colors to extract per artwork and
to return in the aggregated signature palette.
group_by_period: If True and dates are available, also compute
per-decade signatures.
include_figure: If True, generate and return a matplotlib figure.
save_path: Optional path to save the figure instead of displaying it.
random_state: Random seed for reproducible extraction and sampling.
verbose: If True, print progress messages.
Returns:
Dictionary with aggregated palette, metrics, optional per-period
breakdown, and optional figure.
"""
if not isinstance(works, list):
raise ValueError("works must be a list")
for i, work in enumerate(works):
if not isinstance(work, dict):
raise TypeError(f"Item at index {i} is not a dictionary")
if not works:
return self._empty_signature()
import warnings
from sklearn.exceptions import ConvergenceWarning
from renoir.color import ColorExtractor, ColorAnalyzer, ColorNamer
extractor = ColorExtractor()
analyzer = ColorAnalyzer()
namer = ColorNamer()
work_palettes = []
for i, work in enumerate(works):
image = work.get("image")
year = self._parse_year(work)
if image is None:
if verbose:
print(f"Warning: work {i} has no image; skipping")
continue
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", ConvergenceWarning)
palette = extractor.extract_dominant_colors(
image,
n_colors=n_colors,
random_state=random_state,
)
work_palettes.append((work, palette, year))
if verbose and (i + 1) % 5 == 0:
print(f" Processed {i + 1}/{len(works)} works...")
except Exception as e:
if verbose:
print(f"Warning: failed to extract palette for work {i}: {e}")
continue
if not work_palettes:
return self._empty_signature()
all_colors = [color for _, palette, _ in work_palettes for color in palette]
signature_palette = self._aggregate_palette(
all_colors, n_colors, random_state, namer
)
metrics = self._compute_palette_metrics(signature_palette, analyzer)
by_period: Dict[str, Any] = {}
if group_by_period:
period_groups = defaultdict(list)
for work, palette, year in work_palettes:
if year is not None:
decade = (year // 10) * 10
period_groups[decade].append((work, palette, year))
for period in sorted(period_groups.keys()):
group = period_groups[period]
period_colors = [c for _, palette, _ in group for c in palette]
period_palette = self._aggregate_palette(
period_colors, n_colors, random_state, namer
)
by_period[str(period)] = {
"n_works": len(group),
"years": sorted({y for _, _, y in group if y is not None}),
"palette": period_palette,
"metrics": self._compute_palette_metrics(period_palette, analyzer),
}
years = [y for _, _, y in work_palettes if y is not None]
date_range = (min(years), max(years)) if years else None
figure = None
if include_figure:
figure = self._build_signature_figure(
signature_palette, None, by_period, save_path
)
return {
"artist": None,
"n_works_analyzed": len(work_palettes),
"n_works_available": len(works),
"n_works_selected": len(work_palettes),
"requested_strategy": None,
"effective_strategy": None,
"date_range": date_range,
"palette": signature_palette,
"metrics": metrics,
"by_period": by_period,
"work_palettes": [
{
"title": work.get("title", "Unknown"),
"artist": work.get("artist", "Unknown"),
"year": year,
"palette": palette,
}
for work, palette, year in work_palettes
],
"figure": figure,
}
[docs]
def artist_color_signature(
self,
artist_name: str,
limit: int = 10,
n_colors: int = 5,
strategy: str = "temporal",
include_figure: bool = False,
save_path: Optional[str] = None,
random_state: int = 42,
verbose: bool = True,
) -> Dict[str, Any]:
"""
Compute a color signature for an artist from WikiArt.
By default, extracts up to 10 works sampled to maximize temporal
coverage. If no parseable dates are available in the dataset, the
method falls back to random sampling and reports the effective
strategy in the result.
Args:
artist_name: Artist name as it appears in WikiArt.
limit: Maximum number of works to analyze (default: 10).
n_colors: Number of colors in the signature palette.
strategy: Sampling strategy — ``'temporal'`` (default),
``'random'``, or ``'first'``.
include_figure: If True, generate a visualization.
save_path: Optional path to save the figure.
random_state: Seed for reproducible sampling and extraction.
verbose: If True, print progress messages.
Returns:
Dictionary with artist color signature, metrics, optional
per-period breakdown, and optional figure.
"""
if not artist_name or not isinstance(artist_name, str):
raise ValueError("artist_name must be a non-empty string")
if artist_name.strip() == "":
raise ValueError("artist_name cannot be empty or whitespace")
if not isinstance(limit, int):
raise ValueError("limit must be an integer")
if limit < 1:
raise ValueError("limit must be at least 1")
if verbose:
print(f"Computing color signature for {artist_name}...")
# For "first" strategy we can avoid loading the full corpus.
extract_limit = limit if strategy == "first" else None
works = self.extract_artist_works(artist_name, limit=extract_limit)
if not works:
return self._empty_signature(artist_name=artist_name)
actual_limit = min(limit, len(works))
selected, effective_strategy = self._sample_works(
works, actual_limit, strategy, random_state
)
if effective_strategy != strategy and verbose:
print(
f"Note: '{strategy}' sampling not possible (no parseable dates). "
f"Using '{effective_strategy}' instead."
)
if verbose:
print(f"Analyzing {len(selected)} works...")
result = self.analyze_works_color_signature(
selected,
n_colors=n_colors,
group_by_period=True,
include_figure=include_figure,
save_path=save_path,
random_state=random_state,
verbose=verbose,
)
result["artist"] = artist_name
result["n_works_available"] = len(works)
result["n_works_selected"] = len(selected)
result["requested_strategy"] = strategy
result["effective_strategy"] = effective_strategy
return result
[docs]
def quick_analysis(
artist_name: str,
limit: Optional[int] = None,
show_summary: bool = True,
show_plots: bool = False,
) -> List[Dict[str, Any]]:
"""
Quick function to analyze an artist's works with minimal setup.
This is a convenience function for beginners, combining extraction
and analysis in a single call.
Args:
artist_name: Artist name as it appears in WikiArt
limit: Optional maximum number of works to retrieve
show_summary: If True, print a summary of the results
show_plots: If True, display visualization plots (requires matplotlib)
Returns:
List of artwork dictionaries
Examples:
>>> works = quick_analysis('claude-monet', limit=20)
Loading WikiArt dataset...
✓ Loaded 103250 artworks
✓ Found 20 works by claude-monet
Artist Summary:
- Total works: 20
- Primary style: Impressionism
- Primary genre: landscape
- Date range: 1865-1926
>>> works = quick_analysis('claude-monet', limit=20, show_plots=True)
# Displays visualization plots
"""
analyzer = ArtistAnalyzer()
works = analyzer.extract_artist_works(artist_name, limit=limit)
if show_summary and works:
summary = analyzer.get_work_summary(works)
print("\nArtist Summary:")
print(f"- Total works: {summary['total_works']}")
print(f"- Primary style: {summary['primary_style']}")
print(f"- Primary genre: {summary['primary_genre']}")
if summary["date_range"]:
print(
f"- Date range: {summary['date_range'][0]}-{summary['date_range'][1]}"
)
if show_plots:
analyzer.create_artist_overview(artist_name, limit=limit)
return works