Complete Milestone 3 visual features
This commit is contained in:
parent
57ae7b780b
commit
d7c1d87595
13 changed files with 918 additions and 44 deletions
|
|
@ -219,6 +219,10 @@ Treat this defensively. The observed enrichment representation can be a space-se
|
|||
|
||||
If absent or malformed, record a quality flag. Never pretend equal/fallback weights are original SMK weights.
|
||||
|
||||
For bulk-only feature schema `artart-visual-features` v1, use canonical `colors`, deduplicate exact
|
||||
repeated hex values for analysis, and label `1/n` weights explicitly as `uniform`. Preserve source
|
||||
palette/order and never claim source dominance from these weights.
|
||||
|
||||
## Colour space
|
||||
|
||||
Use:
|
||||
|
|
@ -240,15 +244,12 @@ Hue is circular. Do not average hue degrees arithmetically. Aggregate colour cen
|
|||
- `centroid_b`
|
||||
- `centroid_chroma`
|
||||
- `centroid_hue_deg`
|
||||
- `centroid_hue_x = cos(h)`
|
||||
- `centroid_hue_y = sin(h)`
|
||||
|
||||
### Palette distribution
|
||||
|
||||
- `dominant_color_weight`
|
||||
- `palette_entropy_01`
|
||||
- `effective_palette_size`
|
||||
- `palette_spread`
|
||||
- `palette_spread_oklab`
|
||||
- `lightness_stddev`
|
||||
- `chroma_stddev`
|
||||
- `hue_concentration_01`
|
||||
|
|
|
|||
|
|
@ -121,9 +121,10 @@ See:
|
|||
|
||||
## Current status
|
||||
|
||||
Milestones 0–2 complete: source structure and audits are manifested, the language snapshots have
|
||||
Milestones 0–3 complete: source structure and audits are manifested, the language snapshots have
|
||||
bounded-memory source Parquets, DA/EN rows are paired into stable SMK entities, and the exact
|
||||
`smk-public-visual-v1` membership rule is frozen. Visual feature extraction has not started.
|
||||
`smk-public-visual-v1` membership rule is frozen. Deterministic raw visual features and analytical
|
||||
palettes now exist; corpus-wide feature normalization and similarity have not started.
|
||||
|
||||
Audit results are documented in:
|
||||
|
||||
|
|
@ -134,10 +135,13 @@ Audit results are documented in:
|
|||
- [`docs/RIGHTS_AUDIT.md`](docs/RIGHTS_AUDIT.md)
|
||||
- [`docs/CANONICALIZATION.md`](docs/CANONICALIZATION.md)
|
||||
- [`docs/CORPUS_V1.md`](docs/CORPUS_V1.md)
|
||||
- [`docs/FEATURE_EXTRACTION_V1.md`](docs/FEATURE_EXTRACTION_V1.md)
|
||||
- [`docs/FEATURE_EXAMPLES.md`](docs/FEATURE_EXAMPLES.md)
|
||||
|
||||
Run the audited ingest with:
|
||||
|
||||
```bash
|
||||
smk milestone1
|
||||
smk milestone2
|
||||
smk milestone3
|
||||
```
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ Suggested fields:
|
|||
### `features.parquet`
|
||||
One row per artwork with Feature Specification v1 values and quality flags.
|
||||
|
||||
Milestone 3 materializes this as `artwork-visual-features-v1.parquet`, keyed by `smk_entity_id`,
|
||||
and writes `artwork-palettes-v1.parquet` as one typed row per analytical palette colour. Both are
|
||||
bound to exact canonical/corpus SHA-256 values and extractor/schema versions.
|
||||
|
||||
### `normalized-features.parquet`
|
||||
One row per artwork per normalization profile/version, or a wide equivalent if simpler to query.
|
||||
|
||||
|
|
|
|||
58
docs/FEATURE_EXAMPLES.md
Normal file
58
docs/FEATURE_EXAMPLES.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Feature examples
|
||||
|
||||
Values below are deterministic outputs from extractor v1. OKLCH tuples are `(L, C, hue°)`; `null`
|
||||
hue means chroma below `1e-7`.
|
||||
|
||||
## KMS8892 — required corpus example with physical dimensions
|
||||
|
||||
- Source/analytical palette: 7 / 7 colours, uniform weight 1/7.
|
||||
- OKLCH: `#908d41` (0.6297, 0.0986, 106.85), `#fff1dc` (0.9637, 0.0315, 78.17),
|
||||
`#aed7ea` (0.8561, 0.0505, 227.77), `#ceeae5` (0.9152, 0.0302, 183.74),
|
||||
`#b7ac3e` (0.7331, 0.1295, 103.59), `#888888` (0.6268, ~0, null), and
|
||||
`#795025` (0.4679, 0.0801, 64.80).
|
||||
- Centroid OKLab: (0.7418, -0.0118, 0.0406); OKLCH: (0.7418, 0.0423, 106.18°).
|
||||
- Spread 0.1654; hue concentration 0.7040.
|
||||
- SMK visual: brightness 7.1344, contrast 2.9756, saturation 2.0800, entropy 9.6865,
|
||||
colortemp 3.3317.
|
||||
- Netto 78 × 51.5 cm; area 4,017 cm²; physical/image aspect mismatch 0.000048.
|
||||
- Flags: `IMAGE_BUNDLE_OK`.
|
||||
|
||||
## KKS8001 — required duplicate-colour QA example, outside corpus
|
||||
|
||||
- Source palette: `#F4C6A3`, `#F4C6A3`, `#9B9B9B`, `#9B9B9B`, `#9B9B9B`.
|
||||
- Analytical palette: `#f4c6a3` (0.8583, 0.0702, 58.91°) and `#9b9b9b`
|
||||
(0.6895, ~0, null), each with uniform analytical weight 0.5.
|
||||
- Centroid OKLab: (0.7739, 0.0181, 0.0301); OKLCH: (0.7739, 0.0351, 58.91°).
|
||||
- Spread 0.0914; hue concentration 1.0.
|
||||
- Source visual and geometry are unavailable.
|
||||
- Flags include `PALETTE_DUPLICATE_SOURCE_COLOR`, `IMAGE_BUNDLE_INCOMPLETE`,
|
||||
`VISUAL_FIELDS_PARTIAL`, and `PHYSICAL_GEOMETRY_MISSING`.
|
||||
- It is not added to either corpus feature Parquet.
|
||||
|
||||
## KMS1571 — near-achromatic
|
||||
|
||||
- Palette: `#111111`, `#222222`; both have null hue.
|
||||
- Centroid OKLCH: (0.2148, ~0, null).
|
||||
- Spread 0.0372; hue concentration null.
|
||||
- Netto 66 × 40.5 cm.
|
||||
- Flags: `PALETTE_ACHROMATIC`, `CENTROID_ACHROMATIC`,
|
||||
`HUE_CONCENTRATION_UNAVAILABLE`.
|
||||
|
||||
## KAS782 — colourful/high-spread deterministic sample
|
||||
|
||||
- Palette: `#b97438`, `#b7ac3e`, `#000000`, `#33140c`, `#fcaa6b`, uniform weight 0.2.
|
||||
- Chromatic hues cluster around orange/yellow (35.54°–103.59°); black has null hue.
|
||||
- Centroid OKLCH: (0.4793, 0.0772, 68.93°).
|
||||
- Spread 0.2953; hue concentration 0.9149.
|
||||
- SMK saturation 4.7511 and brightness 3.7184.
|
||||
- Physical geometry is unavailable; the image is 1080 × 1080 px.
|
||||
|
||||
## KKS5261 — corpus example without Netto geometry
|
||||
|
||||
- Palette: `#b7ac3e`, `#956830`, `#77733d`, `#ffffff`.
|
||||
- Centroid OKLCH: (0.7081, 0.0714, 93.52°).
|
||||
- Spread 0.1721; hue concentration 0.9618.
|
||||
- Image 4,992 × 6,287 px; physical Netto geometry remains null rather than substituting sheet
|
||||
measurements or pixels.
|
||||
- Flags include `PHYSICAL_GEOMETRY_MISSING`.
|
||||
|
||||
104
docs/FEATURE_EXTRACTION_V1.md
Normal file
104
docs/FEATURE_EXTRACTION_V1.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# ArtArt visual features v1
|
||||
|
||||
Milestone 3 produces deterministic, corpus-independent raw features for every member of the frozen
|
||||
`smk-public-visual-v1` corpus.
|
||||
|
||||
## Version and provenance
|
||||
|
||||
- feature schema: `artart-visual-features`
|
||||
- schema version: `1.0.0`
|
||||
- extractor version: `1.0.0`
|
||||
- colour implementation: `internal-oklab-published-matrices-v1`
|
||||
- source corpus: `smk-public-visual-v1`
|
||||
|
||||
Both Parquet outputs embed the canonical/corpus SHA-256 values and a reproducible generation
|
||||
timestamp anchored to the source manifest. Calculations and storage use float64.
|
||||
|
||||
## Palette policy
|
||||
|
||||
The palette source is canonical SMK bulk `colors`. No documented weights exist, so every analytical
|
||||
colour has uniform weight `1/n`. Source ordering is preserved but is not interpreted as dominance.
|
||||
Exact repeated hex values are deduplicated case-insensitively for analysis while source palette,
|
||||
source rank and source size remain available. Repetition is never treated as weight.
|
||||
|
||||
`KKS8001` demonstrates the rule: five source entries become two analytical colours and receive
|
||||
`PALETTE_DUPLICATE_SOURCE_COLOR`. It is outside the corpus because its image bundle is incomplete,
|
||||
so the flag appears in the QA example rather than the corpus feature table.
|
||||
|
||||
There is deliberately no `dominant_color_weight` in v1. Normalized palette entropy is 0 for a
|
||||
one-colour palette and 1 for every multi-colour palette under uniform weights. Effective palette
|
||||
size consequently equals analytical palette size. Both definitions remain generic so documented
|
||||
weights can replace uniform weights later, but neither should be overinterpreted in v1.
|
||||
|
||||
## Colour conversion and features
|
||||
|
||||
Each `#RRGGBB` value follows:
|
||||
|
||||
```text
|
||||
sRGB -> linear RGB -> OKLab -> OKLCH
|
||||
```
|
||||
|
||||
The implementation uses the published OKLab matrices. Hue is null when OKLCH chroma is below
|
||||
`1e-7`; neutral colours never receive an invented hue of zero.
|
||||
|
||||
Centroids are weighted means in OKLab and only then converted to OKLCH. Additional compact features
|
||||
are normalized entropy, effective palette size, weighted OKLab mean distance from centroid,
|
||||
weighted lightness/chroma standard deviation and chroma-weighted circular hue concentration.
|
||||
|
||||
## Geometry
|
||||
|
||||
Net width/height, area, aspect ratio and natural logs exist only for finite positive, unambiguous
|
||||
canonical Netto dimensions. Gross, sheet, plate, mount and pixels never substitute for missing
|
||||
physical geometry. Pixel width/height and image aspect are separate digital fields.
|
||||
|
||||
Aspect mismatch is:
|
||||
|
||||
```text
|
||||
abs(log(net_aspect_ratio / image_aspect_ratio))
|
||||
```
|
||||
|
||||
Its observed median is 0.0126, P95 0.261 and P99 0.654. The conservative diagnostic threshold is
|
||||
`ln(2) = 0.693147...`, meaning more than a factor-two aspect discrepancy; 19 artworks are flagged.
|
||||
The flag does not change corpus membership.
|
||||
|
||||
## Corpus diagnostics
|
||||
|
||||
| Measure | Count |
|
||||
|---|---:|
|
||||
| Feature rows | 27,254 |
|
||||
| Analytical palette rows | 113,332 |
|
||||
| Achromatic palette-colour rows | 63,206 |
|
||||
| Centroid hue null | 5,766 |
|
||||
| Hue concentration null | 5,766 |
|
||||
| Valid Netto width × height | 2,189 |
|
||||
| Physical geometry missing | 25,065 |
|
||||
| Aspect mismatch flagged | 19 |
|
||||
|
||||
Selected distributions:
|
||||
|
||||
| Feature | Min | Median | P95 | P99 | Max |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| centroid L | 0.1771 | 0.7207 | 0.9571 | 0.9892 | 1.0000 |
|
||||
| centroid chroma | ~0 | 0.0181 | 0.0746 | 0.0976 | 0.1468 |
|
||||
| OKLab palette spread | 0 | 0.1685 | 0.2943 | 0.3689 | 0.5000 |
|
||||
| hue concentration | 0.0279 | 0.9833 | 1.0000 | 1.0000 | 1.0000 |
|
||||
| net area cm² | 5.46 | 3,897.25 | 32,823 | 70,917 | 626,990 |
|
||||
|
||||
Source visual nulls are brightness 62, contrast 62, saturation 871, entropy 1 and colortemp 1.
|
||||
Finite colortemp remains unmodified (range -1.2760 to 15.7201 in the corpus); 143 canonical extreme
|
||||
flags are carried through. Nonfinite source values were already canonical null and none enter this
|
||||
corpus.
|
||||
|
||||
## Proposed future machine vector
|
||||
|
||||
The unfrozen raw candidate is:
|
||||
|
||||
- colour: centroid L/a/b, OKLab spread, lightness stddev, chroma stddev, hue concentration;
|
||||
- SMK visual: brightness, contrast, saturation, entropy;
|
||||
- optional geometry: log net width, log net height, log net area, net aspect ratio.
|
||||
|
||||
Do not include centroid hue alongside a/b, colortemp, raw pixel dimensions or palette entropy as an
|
||||
important independent v1 signal. A future similarity layer should compare only shared available
|
||||
components and renormalize configured feature weights; it must not impute stored nulls. No vector
|
||||
normalization or similarity weighting is implemented here.
|
||||
|
||||
|
|
@ -6,7 +6,10 @@ Per-artwork feature extraction is deterministic and corpus-independent. Running
|
|||
|
||||
## Palette input
|
||||
|
||||
Prefer an explicit SMK weighted palette when valid. Preserve the source representation.
|
||||
Milestone 3 uses canonical SMK bulk `colors` and preserves source representation and order. The
|
||||
bulk snapshots have no documented weights, so v1 uses explicitly labelled uniform analytical
|
||||
weights (`1/n`) after deduplicating exact repeated hex values. Repetition is not interpreted as
|
||||
weight.
|
||||
|
||||
For `colors_palette_weighted_s`, the observed format may contain repeated space-separated hex tokens. Parse defensively:
|
||||
|
||||
|
|
@ -16,7 +19,8 @@ For `colors_palette_weighted_s`, the observed format may contain repeated space-
|
|||
4. retain an explicit `weight_source`,
|
||||
5. quality-flag malformed/partial input.
|
||||
|
||||
If weights are unavailable, do not label equal/fallback weights as original SMK weights. A fallback can be supported if explicitly identified.
|
||||
Uniform v1 weights are `palette_weighting = "uniform"`, never original SMK weights. Future
|
||||
enrichment weights must retain separate provenance.
|
||||
|
||||
Canonical weights must satisfy approximately:
|
||||
|
||||
|
|
@ -52,15 +56,13 @@ Store:
|
|||
- `centroid_b`
|
||||
- `centroid_chroma`
|
||||
- `centroid_hue_deg`
|
||||
- `centroid_hue_x`
|
||||
- `centroid_hue_y`
|
||||
|
||||
Convert the centroid to OKLCH for chroma/hue. Hue is undefined for sufficiently achromatic centroids; use null rather than inventing a direction.
|
||||
|
||||
## Palette statistics
|
||||
|
||||
### `dominant_color_weight`
|
||||
Largest normalized palette weight.
|
||||
There is no `dominant_color_weight` in v1 because uniform weights do not measure image dominance.
|
||||
The first source colour and preserved source order are available without claiming dominance.
|
||||
|
||||
### `palette_entropy_01`
|
||||
Normalized Shannon entropy:
|
||||
|
|
@ -80,7 +82,7 @@ exp(H)
|
|||
|
||||
using unnormalized natural-log entropy.
|
||||
|
||||
### `palette_spread`
|
||||
### `palette_spread_oklab`
|
||||
Weighted mean Euclidean OKLab distance from palette colours to the weighted centroid.
|
||||
|
||||
### `lightness_stddev`
|
||||
|
|
@ -114,17 +116,16 @@ If multiple enrichment components/versions provide competing values, retain prov
|
|||
|
||||
## Geometry
|
||||
|
||||
Canonical geometry/features:
|
||||
Canonical feature geometry:
|
||||
|
||||
- `physical_width_cm`
|
||||
- `physical_height_cm`
|
||||
- `physical_depth_cm`
|
||||
- `physical_area_cm2`
|
||||
- `physical_aspect_ratio`
|
||||
- `net_width_cm`
|
||||
- `net_height_cm`
|
||||
- `net_area_cm2`
|
||||
- `net_aspect_ratio`
|
||||
- `image_aspect_ratio`
|
||||
- `log_width_cm`
|
||||
- `log_height_cm`
|
||||
- `log_area_cm2`
|
||||
- `log_net_width_cm`
|
||||
- `log_net_height_cm`
|
||||
- `log_net_area_cm2`
|
||||
|
||||
Rules:
|
||||
|
||||
|
|
|
|||
|
|
@ -43,32 +43,24 @@ without collapsing duplicate `object_number` groups.
|
|||
Status: complete. See `CANONICALIZATION.md` and `CORPUS_V1.md`. Visual/colour feature extraction
|
||||
has not started.
|
||||
|
||||
## Milestone 3 — Weighted palette + colour conversion
|
||||
## Milestone 3 — Deterministic per-artwork visual features
|
||||
|
||||
- locate ordinary/weighted palette fields in the actual snapshot/enrichment data
|
||||
- parse `colors_palette_weighted_s` when present
|
||||
- normalize explicit weights
|
||||
- implement/verify sRGB -> OKLab -> OKLCH
|
||||
- preserve malformed input as quality warnings rather than crashing the run
|
||||
|
||||
Done when known palette fixtures produce stable colour rows and valid weights.
|
||||
|
||||
## Milestone 4 — Feature extraction
|
||||
|
||||
Implement:
|
||||
Implemented for frozen `smk-public-visual-v1`:
|
||||
|
||||
- perceptual centroid
|
||||
- palette entropy
|
||||
- effective palette size
|
||||
- explicitly uniform bulk-palette weights and duplicate-colour handling
|
||||
- sRGB -> OKLab -> OKLCH with achromatic hue semantics
|
||||
- palette entropy/effective size with documented uniform-weight limitation
|
||||
- palette spread
|
||||
- weighted lightness/chroma variation
|
||||
- hue concentration
|
||||
- SMK visual fields + provenance
|
||||
- physical/image geometry + logs
|
||||
|
||||
Done when one artwork can produce a complete feature record independently of the corpus.
|
||||
Status: complete. Raw features remain corpus-independent; no corpus normalization or similarity is
|
||||
implemented. See `FEATURE_EXTRACTION_V1.md` and `FEATURE_EXAMPLES.md`.
|
||||
|
||||
## Milestone 5 — Quality layer
|
||||
## Milestone 4 — Quality layer
|
||||
|
||||
Implement documented flags and source/language/aspect checks.
|
||||
|
||||
|
|
@ -85,13 +77,13 @@ Produce `quality-report.json` with:
|
|||
- palette-size distribution
|
||||
- obvious extreme values
|
||||
|
||||
## Milestone 6 — Pilot build
|
||||
## Milestone 5 — Pilot build
|
||||
|
||||
Run 100–500 representative artworks.
|
||||
|
||||
Review distributions and manually inspect selected records. Fix parser/source/math errors only; do not artistically tune features.
|
||||
|
||||
## Milestone 7 — Corpus-relative normalization
|
||||
## Milestone 6 — Corpus-relative normalization
|
||||
|
||||
Use the frozen `smk-public-visual-v1` entity membership from Milestone 2. Do not redefine corpus
|
||||
membership from an expected count.
|
||||
|
|
@ -104,7 +96,7 @@ membership from an expected count.
|
|||
- implement robust z-score
|
||||
- freeze profile with source-manifest reference
|
||||
|
||||
## Milestone 8 — Full dataset build
|
||||
## Milestone 7 — Full dataset build
|
||||
|
||||
Run the complete local source snapshots.
|
||||
|
||||
|
|
@ -112,7 +104,7 @@ Definition of done:
|
|||
|
||||
Every canonical artwork has valid applicable features or explicit nulls/flags explaining missing/invalid prerequisites. Every normalization result references a frozen profile and exact source manifest.
|
||||
|
||||
## Milestone 9 — Similarity
|
||||
## Milestone 8 — Similarity
|
||||
|
||||
Implement separate profiles rather than one magic similarity score:
|
||||
|
||||
|
|
@ -122,7 +114,7 @@ Implement separate profiles rather than one magic similarity score:
|
|||
|
||||
Use fast feature-vector candidate retrieval followed optionally by weighted-palette optimal-transport reranking. Do not precompute all pairs.
|
||||
|
||||
## Milestone 10 — Art Tone v1
|
||||
## Milestone 9 — Art Tone v1
|
||||
|
||||
Implement versioned, explainable sonification mapping separate from objective features.
|
||||
|
||||
|
|
@ -138,7 +130,7 @@ Initial mapping:
|
|||
|
||||
Emit exact inputs alongside every mapped result.
|
||||
|
||||
## Milestone 11 — Exploration
|
||||
## Milestone 10 — Exploration
|
||||
|
||||
Only after Dataset v1 is trustworthy, start experimental interfaces/notebooks/apps:
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@ observations to centimetres and write `artworks.parquet`. Freeze `smk-public-vis
|
|||
explicit rights/image/colors criteria and emit membership reasons. Colour feature extraction is a
|
||||
later stage.
|
||||
|
||||
## Stage 3 — deterministic visual features
|
||||
|
||||
For each frozen corpus entity, deduplicate exact source palette colours, assign explicitly uniform
|
||||
analytical weights, convert sRGB to OKLab/OKLCH, calculate compact palette statistics, carry source
|
||||
visual values and derive eligible geometry. Emit scalar feature and long-form palette Parquets plus
|
||||
diagnostics. Do not perform corpus-relative normalization or similarity here.
|
||||
|
||||
## Ingest performance rule
|
||||
|
||||
The source files are large. Use bounded-memory streaming where practical. A one-time conversion to Parquet/Arrow as an interim representation is encouraged if it simplifies all later stages.
|
||||
|
|
|
|||
|
|
@ -37,5 +37,14 @@ def milestone2(root: Path | None = None) -> None:
|
|||
typer.echo(f"Milestone 2 complete: {summary['canonical_entity_count']} entities")
|
||||
|
||||
|
||||
@app.command("milestone3")
|
||||
def milestone3(root: Path | None = None) -> None:
|
||||
"""Build deterministic per-artwork visual features for the frozen corpus."""
|
||||
from smk_playground.features.milestone3 import run
|
||||
|
||||
summary = run((root or Path.cwd()).resolve())
|
||||
typer.echo(f"Milestone 3 complete: {summary['feature_rows']} feature rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
|||
70
src/smk_playground/features/color.py
Normal file
70
src/smk_playground/features/color.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Small deterministic sRGB -> OKLab -> OKLCH implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
ACHROMATIC_EPSILON = 1e-7
|
||||
COLOR_IMPLEMENTATION = "internal-oklab-published-matrices-v1"
|
||||
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConvertedColor:
|
||||
hex: str
|
||||
srgb_r: float
|
||||
srgb_g: float
|
||||
srgb_b: float
|
||||
oklab_l: float
|
||||
oklab_a: float
|
||||
oklab_b: float
|
||||
oklch_l: float
|
||||
oklch_c: float
|
||||
oklch_h_deg: float | None
|
||||
|
||||
|
||||
def _linear_channel(channel: float) -> float:
|
||||
return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def hex_to_oklab(value: str) -> ConvertedColor:
|
||||
if not isinstance(value, str) or not HEX_RE.fullmatch(value):
|
||||
raise ValueError(f"Invalid #RRGGBB color: {value!r}")
|
||||
canonical = value.casefold()
|
||||
r = int(canonical[1:3], 16) / 255.0
|
||||
g = int(canonical[3:5], 16) / 255.0
|
||||
b = int(canonical[5:7], 16) / 255.0
|
||||
linear_r, linear_g, linear_b = map(_linear_channel, (r, g, b))
|
||||
|
||||
l = 0.4122214708 * linear_r + 0.5363325363 * linear_g + 0.0514459929 * linear_b
|
||||
m = 0.2119034982 * linear_r + 0.6806995451 * linear_g + 0.1073969566 * linear_b
|
||||
s = 0.0883024619 * linear_r + 0.2817188376 * linear_g + 0.6299787005 * linear_b
|
||||
l_root = math.copysign(abs(l) ** (1 / 3), l)
|
||||
m_root = math.copysign(abs(m) ** (1 / 3), m)
|
||||
s_root = math.copysign(abs(s) ** (1 / 3), s)
|
||||
oklab_l = 0.2104542553 * l_root + 0.7936177850 * m_root - 0.0040720468 * s_root
|
||||
oklab_a = 1.9779984951 * l_root - 2.4285922050 * m_root + 0.4505937099 * s_root
|
||||
oklab_b = 0.0259040371 * l_root + 0.7827717662 * m_root - 0.8086757660 * s_root
|
||||
chroma, hue = oklab_to_oklch(oklab_l, oklab_a, oklab_b)[1:]
|
||||
return ConvertedColor(
|
||||
hex=canonical,
|
||||
srgb_r=r,
|
||||
srgb_g=g,
|
||||
srgb_b=b,
|
||||
oklab_l=oklab_l,
|
||||
oklab_a=oklab_a,
|
||||
oklab_b=oklab_b,
|
||||
oklch_l=oklab_l,
|
||||
oklch_c=chroma,
|
||||
oklch_h_deg=hue,
|
||||
)
|
||||
|
||||
|
||||
def oklab_to_oklch(lightness: float, a: float, b: float) -> tuple[float, float, float | None]:
|
||||
chroma = math.hypot(a, b)
|
||||
if chroma < ACHROMATIC_EPSILON:
|
||||
return lightness, chroma, None
|
||||
return lightness, chroma, math.degrees(math.atan2(b, a)) % 360.0
|
||||
|
||||
214
src/smk_playground/features/extract.py
Normal file
214
src/smk_playground/features/extract.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Pure per-artwork feature extraction for artart-visual-features v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .color import ACHROMATIC_EPSILON, ConvertedColor, hex_to_oklab, oklab_to_oklch
|
||||
|
||||
PALETTE_SOURCE = "smk_bulk_colors"
|
||||
PALETTE_WEIGHTING = "uniform"
|
||||
ASPECT_MISMATCH_THRESHOLD = math.log(2.0)
|
||||
|
||||
|
||||
def analytical_palette(source_colors: list[str] | None) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
flags: list[str] = []
|
||||
if not source_colors:
|
||||
return [], ["PALETTE_EMPTY"]
|
||||
unique: list[tuple[int, str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for source_rank, source_hex in enumerate(source_colors, start=1):
|
||||
canonical = source_hex.casefold()
|
||||
if canonical in seen:
|
||||
if "PALETTE_DUPLICATE_SOURCE_COLOR" not in flags:
|
||||
flags.append("PALETTE_DUPLICATE_SOURCE_COLOR")
|
||||
continue
|
||||
seen.add(canonical)
|
||||
unique.append((source_rank, source_hex, canonical))
|
||||
if not unique:
|
||||
return [], sorted({*flags, "PALETTE_EMPTY"})
|
||||
weight = 1.0 / len(unique)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for palette_rank, (source_rank, source_hex, canonical) in enumerate(unique, start=1):
|
||||
converted = hex_to_oklab(canonical)
|
||||
rows.append(_palette_row(palette_rank, source_rank, source_hex, weight, converted))
|
||||
if all(row["oklch_h_deg"] is None for row in rows):
|
||||
flags.append("PALETTE_ACHROMATIC")
|
||||
return rows, sorted(flags)
|
||||
|
||||
|
||||
def _palette_row(
|
||||
palette_rank: int,
|
||||
source_rank: int,
|
||||
source_hex: str,
|
||||
weight: float,
|
||||
color: ConvertedColor,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"palette_rank": palette_rank,
|
||||
"source_rank": source_rank,
|
||||
"source_hex": source_hex,
|
||||
"hex": color.hex,
|
||||
"weight": weight,
|
||||
"srgb_r": color.srgb_r,
|
||||
"srgb_g": color.srgb_g,
|
||||
"srgb_b": color.srgb_b,
|
||||
"oklab_l": color.oklab_l,
|
||||
"oklab_a": color.oklab_a,
|
||||
"oklab_b": color.oklab_b,
|
||||
"oklch_l": color.oklch_l,
|
||||
"oklch_c": color.oklch_c,
|
||||
"oklch_h_deg": color.oklch_h_deg,
|
||||
}
|
||||
|
||||
|
||||
def _weighted_mean(values: list[float], weights: list[float]) -> float:
|
||||
return math.fsum(weight * value for value, weight in zip(values, weights, strict=True))
|
||||
|
||||
|
||||
def _weighted_stddev(values: list[float], weights: list[float], mean: float) -> float:
|
||||
variance = math.fsum(
|
||||
weight * (value - mean) ** 2 for value, weight in zip(values, weights, strict=True)
|
||||
)
|
||||
return math.sqrt(max(0.0, variance))
|
||||
|
||||
|
||||
def palette_features(palette: list[dict[str, Any]]) -> tuple[dict[str, float | None], list[str]]:
|
||||
if not palette:
|
||||
return {
|
||||
"centroid_l": None, "centroid_a": None, "centroid_b": None,
|
||||
"centroid_chroma": None, "centroid_hue_deg": None,
|
||||
"palette_entropy_01": None, "effective_palette_size": None,
|
||||
"palette_spread_oklab": None, "lightness_stddev": None,
|
||||
"chroma_stddev": None, "hue_concentration_01": None,
|
||||
}, ["PALETTE_EMPTY", "HUE_CONCENTRATION_UNAVAILABLE"]
|
||||
weights = [row["weight"] for row in palette]
|
||||
lightness = [row["oklab_l"] for row in palette]
|
||||
a_values = [row["oklab_a"] for row in palette]
|
||||
b_values = [row["oklab_b"] for row in palette]
|
||||
chroma = [row["oklch_c"] for row in palette]
|
||||
centroid_l = _weighted_mean(lightness, weights)
|
||||
centroid_a = _weighted_mean(a_values, weights)
|
||||
centroid_b = _weighted_mean(b_values, weights)
|
||||
_, centroid_chroma, centroid_hue = oklab_to_oklch(centroid_l, centroid_a, centroid_b)
|
||||
entropy = -math.fsum(weight * math.log(weight) for weight in weights if weight > 0)
|
||||
n = len(palette)
|
||||
entropy_01 = 0.0 if n <= 1 else min(1.0, max(0.0, entropy / math.log(n)))
|
||||
spread = math.fsum(
|
||||
row["weight"] * math.sqrt(
|
||||
(row["oklab_l"] - centroid_l) ** 2
|
||||
+ (row["oklab_a"] - centroid_a) ** 2
|
||||
+ (row["oklab_b"] - centroid_b) ** 2
|
||||
)
|
||||
for row in palette
|
||||
)
|
||||
mean_chroma = _weighted_mean(chroma, weights)
|
||||
hue_x = 0.0
|
||||
hue_y = 0.0
|
||||
chromatic_weight = 0.0
|
||||
for row in palette:
|
||||
if row["oklch_h_deg"] is None or row["oklch_c"] < ACHROMATIC_EPSILON:
|
||||
continue
|
||||
effective_weight = row["weight"] * row["oklch_c"]
|
||||
radians = math.radians(row["oklch_h_deg"])
|
||||
hue_x += effective_weight * math.cos(radians)
|
||||
hue_y += effective_weight * math.sin(radians)
|
||||
chromatic_weight += effective_weight
|
||||
flags: list[str] = []
|
||||
if chromatic_weight < ACHROMATIC_EPSILON:
|
||||
hue_concentration = None
|
||||
flags.extend(["PALETTE_ACHROMATIC", "HUE_CONCENTRATION_UNAVAILABLE"])
|
||||
else:
|
||||
hue_concentration = min(1.0, math.hypot(hue_x, hue_y) / chromatic_weight)
|
||||
if centroid_hue is None:
|
||||
flags.append("CENTROID_ACHROMATIC")
|
||||
return {
|
||||
"centroid_l": centroid_l,
|
||||
"centroid_a": centroid_a,
|
||||
"centroid_b": centroid_b,
|
||||
"centroid_chroma": centroid_chroma,
|
||||
"centroid_hue_deg": centroid_hue,
|
||||
"palette_entropy_01": entropy_01,
|
||||
"effective_palette_size": math.exp(entropy),
|
||||
"palette_spread_oklab": spread,
|
||||
"lightness_stddev": _weighted_stddev(lightness, weights, centroid_l),
|
||||
"chroma_stddev": _weighted_stddev(chroma, weights, mean_chroma),
|
||||
"hue_concentration_01": hue_concentration,
|
||||
}, sorted(set(flags))
|
||||
|
||||
|
||||
def geometry_features(canonical: dict[str, Any]) -> tuple[dict[str, float | int | None], list[str]]:
|
||||
width = canonical.get("net_width_cm")
|
||||
height = canonical.get("net_height_cm")
|
||||
image_width = canonical.get("image_width")
|
||||
image_height = canonical.get("image_height")
|
||||
valid_physical = all(
|
||||
isinstance(value, (int, float)) and math.isfinite(value) and value > 0
|
||||
for value in (width, height)
|
||||
)
|
||||
valid_image = all(
|
||||
isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||
for value in (image_width, image_height)
|
||||
)
|
||||
flags: list[str] = []
|
||||
if valid_physical:
|
||||
area = width * height
|
||||
physical_aspect = width / height
|
||||
else:
|
||||
width = height = area = physical_aspect = None
|
||||
flags.append("PHYSICAL_GEOMETRY_MISSING")
|
||||
image_aspect = image_width / image_height if valid_image else None
|
||||
mismatch = (
|
||||
abs(math.log(physical_aspect / image_aspect))
|
||||
if physical_aspect is not None and image_aspect is not None else None
|
||||
)
|
||||
if (
|
||||
ASPECT_MISMATCH_THRESHOLD is not None and mismatch is not None
|
||||
and mismatch > ASPECT_MISMATCH_THRESHOLD
|
||||
):
|
||||
flags.append("PHYSICAL_IMAGE_ASPECT_MISMATCH")
|
||||
return {
|
||||
"net_width_cm": width,
|
||||
"net_height_cm": height,
|
||||
"net_area_cm2": area,
|
||||
"net_aspect_ratio": physical_aspect,
|
||||
"log_net_width_cm": math.log(width) if width is not None else None,
|
||||
"log_net_height_cm": math.log(height) if height is not None else None,
|
||||
"log_net_area_cm2": math.log(area) if area is not None else None,
|
||||
"image_width_px": image_width if valid_image else None,
|
||||
"image_height_px": image_height if valid_image else None,
|
||||
"image_aspect_ratio": image_aspect,
|
||||
"aspect_mismatch_log": mismatch,
|
||||
}, flags
|
||||
|
||||
|
||||
def extract_artwork(canonical: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
source_colors = canonical.get("colors")
|
||||
palette, palette_flags = analytical_palette(source_colors)
|
||||
calculated, calculated_flags = palette_features(palette)
|
||||
geometry, geometry_flags = geometry_features(canonical)
|
||||
feature_flags = sorted({*palette_flags, *calculated_flags, *geometry_flags})
|
||||
canonical_flags = sorted(canonical.get("quality_flags") or [])
|
||||
feature = {
|
||||
"smk_entity_id": canonical["smk_entity_id"],
|
||||
"object_number": canonical.get("object_number"),
|
||||
"palette_source": PALETTE_SOURCE,
|
||||
"palette_weighting": PALETTE_WEIGHTING,
|
||||
"palette_source_size": len(source_colors or []),
|
||||
"palette_analytic_size": len(palette),
|
||||
"source_palette": source_colors,
|
||||
"first_palette_color_hex": source_colors[0] if source_colors else None,
|
||||
"palette_source_order_preserved": True,
|
||||
**calculated,
|
||||
"smk_brightness": canonical.get("brightness"),
|
||||
"smk_contrast": canonical.get("contrast"),
|
||||
"smk_saturation": canonical.get("saturation"),
|
||||
"smk_entropy": canonical.get("entropy"),
|
||||
"smk_colortemp": canonical.get("colortemp"),
|
||||
**geometry,
|
||||
"canonical_quality_flags": canonical_flags,
|
||||
"feature_quality_flags": feature_flags,
|
||||
"quality_flags": sorted({*canonical_flags, *feature_flags}),
|
||||
}
|
||||
return feature, palette
|
||||
324
src/smk_playground/features/milestone3.py
Normal file
324
src/smk_playground/features/milestone3.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Build deterministic per-artwork visual features for smk-public-visual-v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from smk_playground.features.color import ACHROMATIC_EPSILON, COLOR_IMPLEMENTATION
|
||||
from smk_playground.features.extract import ASPECT_MISMATCH_THRESHOLD, extract_artwork
|
||||
|
||||
FEATURE_SCHEMA_NAME = "artart-visual-features"
|
||||
FEATURE_SCHEMA_VERSION = "1.0.0"
|
||||
EXTRACTOR_VERSION = "1.0.0"
|
||||
CORPUS_VERSION = "smk-public-visual-v1"
|
||||
|
||||
PROVENANCE_FIELDS = [
|
||||
("feature_schema", pa.string()),
|
||||
("feature_schema_version", pa.string()),
|
||||
("extractor_version", pa.string()),
|
||||
("generated_at_utc", pa.string()),
|
||||
("canonical_dataset_sha256", pa.string()),
|
||||
("corpus_definition", pa.string()),
|
||||
("corpus_dataset_sha256", pa.string()),
|
||||
("color_implementation", pa.string()),
|
||||
]
|
||||
|
||||
FEATURE_SCHEMA = pa.schema([
|
||||
*PROVENANCE_FIELDS,
|
||||
("smk_entity_id", pa.string()),
|
||||
("object_number", pa.string()),
|
||||
("palette_source", pa.string()),
|
||||
("palette_weighting", pa.string()),
|
||||
("palette_source_size", pa.int64()),
|
||||
("palette_analytic_size", pa.int64()),
|
||||
("source_palette", pa.list_(pa.string())),
|
||||
("first_palette_color_hex", pa.string()),
|
||||
("palette_source_order_preserved", pa.bool_()),
|
||||
("centroid_l", pa.float64()),
|
||||
("centroid_a", pa.float64()),
|
||||
("centroid_b", pa.float64()),
|
||||
("centroid_chroma", pa.float64()),
|
||||
("centroid_hue_deg", pa.float64()),
|
||||
("palette_entropy_01", pa.float64()),
|
||||
("effective_palette_size", pa.float64()),
|
||||
("palette_spread_oklab", pa.float64()),
|
||||
("lightness_stddev", pa.float64()),
|
||||
("chroma_stddev", pa.float64()),
|
||||
("hue_concentration_01", pa.float64()),
|
||||
("smk_brightness", pa.float64()),
|
||||
("smk_contrast", pa.float64()),
|
||||
("smk_saturation", pa.float64()),
|
||||
("smk_entropy", pa.float64()),
|
||||
("smk_colortemp", pa.float64()),
|
||||
("net_width_cm", pa.float64()),
|
||||
("net_height_cm", pa.float64()),
|
||||
("net_area_cm2", pa.float64()),
|
||||
("net_aspect_ratio", pa.float64()),
|
||||
("log_net_width_cm", pa.float64()),
|
||||
("log_net_height_cm", pa.float64()),
|
||||
("log_net_area_cm2", pa.float64()),
|
||||
("image_width_px", pa.int64()),
|
||||
("image_height_px", pa.int64()),
|
||||
("image_aspect_ratio", pa.float64()),
|
||||
("aspect_mismatch_log", pa.float64()),
|
||||
("canonical_quality_flags", pa.list_(pa.string())),
|
||||
("feature_quality_flags", pa.list_(pa.string())),
|
||||
("quality_flags", pa.list_(pa.string())),
|
||||
])
|
||||
|
||||
PALETTE_SCHEMA = pa.schema([
|
||||
*PROVENANCE_FIELDS,
|
||||
("smk_entity_id", pa.string()),
|
||||
("object_number", pa.string()),
|
||||
("palette_source", pa.string()),
|
||||
("palette_weighting", pa.string()),
|
||||
("palette_rank", pa.int64()),
|
||||
("source_rank", pa.int64()),
|
||||
("source_hex", pa.string()),
|
||||
("hex", pa.string()),
|
||||
("weight", pa.float64()),
|
||||
("srgb_r", pa.float64()),
|
||||
("srgb_g", pa.float64()),
|
||||
("srgb_b", pa.float64()),
|
||||
("oklab_l", pa.float64()),
|
||||
("oklab_a", pa.float64()),
|
||||
("oklab_b", pa.float64()),
|
||||
("oklch_l", pa.float64()),
|
||||
("oklch_c", pa.float64()),
|
||||
("oklch_h_deg", pa.float64()),
|
||||
])
|
||||
|
||||
DIAGNOSTIC_FIELDS = (
|
||||
"centroid_l", "centroid_chroma", "palette_spread_oklab", "lightness_stddev",
|
||||
"chroma_stddev", "palette_entropy_01", "effective_palette_size",
|
||||
"hue_concentration_01", "smk_brightness", "smk_contrast", "smk_saturation",
|
||||
"smk_entropy", "smk_colortemp", "net_area_cm2", "net_aspect_ratio",
|
||||
"image_aspect_ratio", "aspect_mismatch_log",
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def percentiles(values: list[float]) -> dict[str, float | int | None]:
|
||||
finite = sorted(value for value in values if value is not None and math.isfinite(value))
|
||||
if not finite:
|
||||
return {"count": 0, "min": None, "p01": None, "p05": None, "p25": None,
|
||||
"median": None, "p75": None, "p95": None, "p99": None, "max": None}
|
||||
|
||||
def quantile(probability: float) -> float:
|
||||
position = (len(finite) - 1) * probability
|
||||
low = int(position)
|
||||
high = min(low + 1, len(finite) - 1)
|
||||
fraction = position - low
|
||||
return finite[low] * (1 - fraction) + finite[high] * fraction
|
||||
|
||||
return {
|
||||
"count": len(finite), "min": finite[0], "p01": quantile(0.01),
|
||||
"p05": quantile(0.05), "p25": quantile(0.25), "median": statistics.median(finite),
|
||||
"p75": quantile(0.75), "p95": quantile(0.95), "p99": quantile(0.99),
|
||||
"max": finite[-1],
|
||||
}
|
||||
|
||||
|
||||
def _provenance(root: Path, canonical_path: Path, corpus_path: Path) -> dict[str, str]:
|
||||
manifest = json.loads(
|
||||
(root / "data" / "processed" / "source-manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
return {
|
||||
"feature_schema": FEATURE_SCHEMA_NAME,
|
||||
"feature_schema_version": FEATURE_SCHEMA_VERSION,
|
||||
"extractor_version": EXTRACTOR_VERSION,
|
||||
# A fixed manifest timestamp makes identical inputs byte-reproducible across repeated runs.
|
||||
"generated_at_utc": manifest["generated_at_utc"],
|
||||
"canonical_dataset_sha256": sha256_file(canonical_path),
|
||||
"corpus_definition": CORPUS_VERSION,
|
||||
"corpus_dataset_sha256": sha256_file(corpus_path),
|
||||
"color_implementation": COLOR_IMPLEMENTATION,
|
||||
}
|
||||
|
||||
|
||||
def _metadata(provenance: dict[str, str]) -> dict[bytes, bytes]:
|
||||
return {f"smk.{key}".encode(): value.encode() for key, value in provenance.items()}
|
||||
|
||||
|
||||
def _example_payload(
|
||||
feature: dict[str, Any], palette: list[dict[str, Any]], in_corpus: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"in_corpus": in_corpus,
|
||||
"smk_entity_id": feature["smk_entity_id"],
|
||||
"object_number": feature["object_number"],
|
||||
"source_palette": feature["source_palette"],
|
||||
"analytical_palette": [
|
||||
{
|
||||
"rank": row["palette_rank"], "source_rank": row["source_rank"],
|
||||
"hex": row["hex"], "weight": row["weight"], "oklch_l": row["oklch_l"],
|
||||
"oklch_c": row["oklch_c"], "oklch_h_deg": row["oklch_h_deg"],
|
||||
}
|
||||
for row in palette
|
||||
],
|
||||
"centroid": {
|
||||
key: feature[key] for key in
|
||||
("centroid_l", "centroid_a", "centroid_b", "centroid_chroma", "centroid_hue_deg")
|
||||
},
|
||||
"palette_spread_oklab": feature["palette_spread_oklab"],
|
||||
"hue_concentration_01": feature["hue_concentration_01"],
|
||||
"source_visual": {
|
||||
key: feature[key] for key in
|
||||
("smk_brightness", "smk_contrast", "smk_saturation", "smk_entropy", "smk_colortemp")
|
||||
},
|
||||
"geometry": {
|
||||
key: feature[key] for key in
|
||||
("net_width_cm", "net_height_cm", "net_area_cm2", "net_aspect_ratio",
|
||||
"image_width_px", "image_height_px", "image_aspect_ratio", "aspect_mismatch_log")
|
||||
},
|
||||
"flags": feature["quality_flags"],
|
||||
}
|
||||
|
||||
|
||||
def run(root: Path) -> dict[str, Any]:
|
||||
canonical_path = root / "data" / "processed" / "artworks.parquet"
|
||||
corpus_path = root / "data" / "processed" / "smk-public-visual-v1.parquet"
|
||||
features_path = root / "data" / "processed" / "artwork-visual-features-v1.parquet"
|
||||
palettes_path = root / "data" / "processed" / "artwork-palettes-v1.parquet"
|
||||
report_path = root / "data" / "reports" / "feature-summary.json"
|
||||
examples_path = root / "data" / "reports" / "feature-examples.json"
|
||||
provenance = _provenance(root, canonical_path, corpus_path)
|
||||
metadata = _metadata(provenance)
|
||||
feature_writer = pq.ParquetWriter(features_path, FEATURE_SCHEMA.with_metadata(metadata), compression="zstd")
|
||||
palette_writer = pq.ParquetWriter(palettes_path, PALETTE_SCHEMA.with_metadata(metadata), compression="zstd")
|
||||
diagnostics: dict[str, list[float]] = {field: [] for field in DIAGNOSTIC_FIELDS}
|
||||
palette_sizes: Counter[int] = Counter()
|
||||
flag_counts: Counter[str] = Counter()
|
||||
feature_flag_counts: Counter[str] = Counter()
|
||||
visual_nulls: Counter[str] = Counter()
|
||||
palette_rows_count = 0
|
||||
palette_achromatic_colors = 0
|
||||
centroid_null_hue_count = 0
|
||||
hue_concentration_null_count = 0
|
||||
examples: dict[str, dict[str, Any]] = {}
|
||||
selectors: dict[str, tuple[float, dict[str, Any], list[dict[str, Any]]] | None] = {
|
||||
"near_achromatic": None, "high_spread": None,
|
||||
}
|
||||
physical_example: tuple[dict[str, Any], list[dict[str, Any]]] | None = None
|
||||
missing_physical_example: tuple[dict[str, Any], list[dict[str, Any]]] | None = None
|
||||
feature_count = 0
|
||||
try:
|
||||
parquet = pq.ParquetFile(corpus_path)
|
||||
for batch in parquet.iter_batches(batch_size=1_000):
|
||||
feature_rows: list[dict[str, Any]] = []
|
||||
palette_rows: list[dict[str, Any]] = []
|
||||
for canonical in batch.to_pylist():
|
||||
feature, palette = extract_artwork(canonical)
|
||||
feature_count += 1
|
||||
palette_sizes[feature["palette_analytic_size"]] += 1
|
||||
for flag in feature["quality_flags"]:
|
||||
flag_counts[flag] += 1
|
||||
for flag in feature["feature_quality_flags"]:
|
||||
feature_flag_counts[flag] += 1
|
||||
if feature["centroid_hue_deg"] is None:
|
||||
centroid_null_hue_count += 1
|
||||
if feature["hue_concentration_01"] is None:
|
||||
hue_concentration_null_count += 1
|
||||
for field in DIAGNOSTIC_FIELDS:
|
||||
value = feature[field]
|
||||
if value is not None:
|
||||
diagnostics[field].append(value)
|
||||
for field in ("smk_brightness", "smk_contrast", "smk_saturation", "smk_entropy", "smk_colortemp"):
|
||||
if feature[field] is None:
|
||||
visual_nulls[field] += 1
|
||||
if feature["object_number"] == "KMS8892":
|
||||
examples["KMS8892"] = _example_payload(feature, palette, True)
|
||||
chroma = feature["centroid_chroma"]
|
||||
if chroma is not None and (
|
||||
selectors["near_achromatic"] is None or chroma < selectors["near_achromatic"][0]
|
||||
):
|
||||
selectors["near_achromatic"] = (chroma, feature, palette)
|
||||
spread = feature["palette_spread_oklab"]
|
||||
if spread is not None and chroma is not None and chroma >= 0.07 and (
|
||||
selectors["high_spread"] is None or spread > selectors["high_spread"][0]
|
||||
):
|
||||
selectors["high_spread"] = (spread, feature, palette)
|
||||
if physical_example is None and feature["net_area_cm2"] is not None:
|
||||
physical_example = (feature, palette)
|
||||
if missing_physical_example is None and feature["net_area_cm2"] is None:
|
||||
missing_physical_example = (feature, palette)
|
||||
feature_rows.append({**provenance, **feature})
|
||||
for palette_row in palette:
|
||||
if palette_row["oklch_h_deg"] is None:
|
||||
palette_achromatic_colors += 1
|
||||
palette_rows.append({
|
||||
**provenance,
|
||||
"smk_entity_id": feature["smk_entity_id"],
|
||||
"object_number": feature["object_number"],
|
||||
"palette_source": feature["palette_source"],
|
||||
"palette_weighting": feature["palette_weighting"],
|
||||
**palette_row,
|
||||
})
|
||||
if abs(math.fsum(row["weight"] for row in palette) - 1.0) > 1e-12:
|
||||
raise ValueError(f"Palette weights do not sum to one: {feature['smk_entity_id']}")
|
||||
feature_writer.write_table(pa.Table.from_pylist(feature_rows, schema=FEATURE_SCHEMA))
|
||||
palette_writer.write_table(pa.Table.from_pylist(palette_rows, schema=PALETTE_SCHEMA))
|
||||
palette_rows_count += len(palette_rows)
|
||||
finally:
|
||||
feature_writer.close()
|
||||
palette_writer.close()
|
||||
|
||||
for name, selected in selectors.items():
|
||||
if selected:
|
||||
examples[name] = _example_payload(selected[1], selected[2], True)
|
||||
if physical_example:
|
||||
examples["with_physical_dimensions"] = _example_payload(*physical_example, True)
|
||||
if missing_physical_example:
|
||||
examples["without_physical_dimensions"] = _example_payload(*missing_physical_example, True)
|
||||
|
||||
kks_table = pq.read_table(canonical_path, filters=[("object_number", "=", "KKS8001")])
|
||||
if kks_table.num_rows == 1:
|
||||
kks_feature, kks_palette = extract_artwork(kks_table.to_pylist()[0])
|
||||
examples["KKS8001"] = _example_payload(kks_feature, kks_palette, False)
|
||||
|
||||
summary = {
|
||||
**provenance,
|
||||
"achromatic_epsilon": ACHROMATIC_EPSILON,
|
||||
"aspect_mismatch_threshold_log": ASPECT_MISMATCH_THRESHOLD,
|
||||
"feature_rows": feature_count,
|
||||
"palette_rows": palette_rows_count,
|
||||
"palette_size_distribution": dict(sorted(palette_sizes.items())),
|
||||
"palette_achromatic_color_rows": palette_achromatic_colors,
|
||||
"centroid_null_hue_count": centroid_null_hue_count,
|
||||
"hue_concentration_null_count": hue_concentration_null_count,
|
||||
"feature_quality_flag_counts": dict(sorted(feature_flag_counts.items())),
|
||||
"all_quality_flag_counts": dict(sorted(flag_counts.items())),
|
||||
"source_visual_null_counts": dict(visual_nulls),
|
||||
"distributions": {field: percentiles(values) for field, values in diagnostics.items()},
|
||||
}
|
||||
report_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
examples_path.write_text(json.dumps(examples, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(run(args.root.resolve()), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
86
tests/test_color_features.py
Normal file
86
tests/test_color_features.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from smk_playground.features.color import hex_to_oklab, oklab_to_oklch
|
||||
from smk_playground.features.extract import (
|
||||
analytical_palette,
|
||||
extract_artwork,
|
||||
palette_features,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hex_value", "expected"),
|
||||
[
|
||||
("#ff0000", (0.62795536, 0.22486306, 0.12584630)),
|
||||
("#00ff00", (0.86643961, -0.23388757, 0.17949848)),
|
||||
("#0000ff", (0.45201372, -0.03245698, -0.31152815)),
|
||||
],
|
||||
)
|
||||
def test_known_srgb_oklab_reference_colors(hex_value: str, expected: tuple[float, ...]) -> None:
|
||||
color = hex_to_oklab(hex_value)
|
||||
assert (color.oklab_l, color.oklab_a, color.oklab_b) == pytest.approx(expected, abs=2e-7)
|
||||
|
||||
|
||||
def test_achromatic_oklch_hue_is_null() -> None:
|
||||
assert oklab_to_oklch(0.5, 0.0, 0.0) == (0.5, 0.0, None)
|
||||
assert hex_to_oklab("#ffffff").oklch_h_deg is None
|
||||
|
||||
|
||||
def test_uniform_weights_and_duplicate_source_colors() -> None:
|
||||
palette, flags = analytical_palette(["#FF0000", "#ff0000", "#0000ff"])
|
||||
assert len(palette) == 2
|
||||
assert math.fsum(row["weight"] for row in palette) == pytest.approx(1.0)
|
||||
assert [row["source_rank"] for row in palette] == [1, 3]
|
||||
assert flags == ["PALETTE_DUPLICATE_SOURCE_COLOR"]
|
||||
|
||||
|
||||
def test_one_color_palette_boundaries() -> None:
|
||||
palette, _ = analytical_palette(["#ff0000"])
|
||||
features, _ = palette_features(palette)
|
||||
assert features["palette_entropy_01"] == 0.0
|
||||
assert features["effective_palette_size"] == pytest.approx(1.0)
|
||||
assert features["palette_spread_oklab"] == pytest.approx(0.0)
|
||||
assert features["hue_concentration_01"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_two_color_centroid_entropy_spread_and_hue() -> None:
|
||||
palette, _ = analytical_palette(["#ff0000", "#0000ff"])
|
||||
features, _ = palette_features(palette)
|
||||
red = hex_to_oklab("#ff0000")
|
||||
blue = hex_to_oklab("#0000ff")
|
||||
assert features["centroid_l"] == pytest.approx((red.oklab_l + blue.oklab_l) / 2)
|
||||
assert features["centroid_a"] == pytest.approx((red.oklab_a + blue.oklab_a) / 2)
|
||||
assert features["palette_entropy_01"] == pytest.approx(1.0)
|
||||
assert features["effective_palette_size"] == pytest.approx(2.0)
|
||||
assert 0 < features["palette_spread_oklab"] < 1
|
||||
assert 0 <= features["hue_concentration_01"] <= 1
|
||||
|
||||
|
||||
def test_geometry_logs_and_null_propagation_are_deterministic() -> None:
|
||||
canonical = {
|
||||
"smk_entity_id": "entity", "object_number": "A1", "colors": ["#ffffff"],
|
||||
"net_width_cm": 10.0, "net_height_cm": 20.0,
|
||||
"image_width": 100, "image_height": 200, "quality_flags": [],
|
||||
}
|
||||
first, _ = extract_artwork(canonical)
|
||||
second, _ = extract_artwork(dict(canonical))
|
||||
assert first == second
|
||||
assert first["net_area_cm2"] == 200.0
|
||||
assert first["log_net_area_cm2"] == pytest.approx(math.log(200.0))
|
||||
canonical["net_width_cm"] = None
|
||||
missing, _ = extract_artwork(canonical)
|
||||
assert missing["net_area_cm2"] is None
|
||||
assert "PHYSICAL_GEOMETRY_MISSING" in missing["feature_quality_flags"]
|
||||
|
||||
|
||||
def test_conservative_aspect_mismatch_flag() -> None:
|
||||
canonical = {
|
||||
"smk_entity_id": "entity", "object_number": "A1", "colors": ["#ffffff"],
|
||||
"net_width_cm": 10.0, "net_height_cm": 10.0,
|
||||
"image_width": 100, "image_height": 300, "quality_flags": [],
|
||||
}
|
||||
feature, _ = extract_artwork(canonical)
|
||||
assert feature["aspect_mismatch_log"] == pytest.approx(math.log(3.0))
|
||||
assert "PHYSICAL_IMAGE_ASPECT_MISMATCH" in feature["feature_quality_flags"]
|
||||
Loading…
Add table
Reference in a new issue