Upload folder using huggingface_hub
Browse files- README.md +40 -5
- app.py +132 -0
- compose/__init__.py +0 -0
- compose/merge.py +116 -0
- compose/provenance.py +140 -0
- registry/__init__.py +23 -0
- registry/loras.json +67 -0
- requirements.txt +14 -0
README.md
CHANGED
|
@@ -1,13 +1,48 @@
|
|
| 1 |
---
|
| 2 |
title: Visual Lineage
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Visual Lineage
|
| 3 |
+
emoji: 🧬
|
| 4 |
+
colorFrom: green
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.0.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: LoRA provenance for imagined instruments.
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Visual Lineage
|
| 15 |
+
|
| 16 |
+
*A 23andMe for imagined instruments.*
|
| 17 |
+
|
| 18 |
+
Visual Lineage trains culturally specific LoRAs for niche instruments that general image models barely know, then traces every generated image back through its visual ancestry.
|
| 19 |
+
|
| 20 |
+
This hackathon prototype starts with:
|
| 21 |
+
|
| 22 |
+
- **LoRA:** [`eritrean_krar_v1`](https://huggingface.co/build-small-hackathon/visual-lineage-eritrean_krar_v1)
|
| 23 |
+
- **Subject:** Eritrean krar / Horn of Africa bowl lyre
|
| 24 |
+
- **Base inference model:** `black-forest-labs/FLUX.2-klein-4B`
|
| 25 |
+
- **Training target:** `black-forest-labs/FLUX.2-klein-base-4B`
|
| 26 |
+
- **Dataset:** 38 openly licensed images, with source/license manifest in the LoRA repo
|
| 27 |
+
|
| 28 |
+
## What the app does
|
| 29 |
+
|
| 30 |
+
1. Select one or more LoRA lineage roots.
|
| 31 |
+
2. Generate an imagined instrument image with FLUX.2 klein.
|
| 32 |
+
3. Show the ancestry breakdown, trigger words, Hugging Face source links, and raw provenance JSON.
|
| 33 |
+
|
| 34 |
+
## Model size / Build Small compliance
|
| 35 |
+
|
| 36 |
+
Every model used is below the 32B parameter cap. The live image model is FLUX.2 [klein] 4B.
|
| 37 |
+
|
| 38 |
+
## Notes
|
| 39 |
+
|
| 40 |
+
Live generation requires GPU and Hugging Face access to the FLUX.2 klein model. If the Space is asleep/cold, the first generation can take a while.
|
| 41 |
+
|
| 42 |
+
## Demo video
|
| 43 |
+
|
| 44 |
+
TODO: add demo video link.
|
| 45 |
+
|
| 46 |
+
## Social post
|
| 47 |
+
|
| 48 |
+
TODO: add social post link.
|
app.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
import traceback
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
|
| 14 |
+
from compose.merge import compose
|
| 15 |
+
from compose.provenance import provenance_sentence
|
| 16 |
+
from registry import lora_choices
|
| 17 |
+
|
| 18 |
+
SAGE = "#5f6f52"
|
| 19 |
+
CREAM = "#f6f1e8"
|
| 20 |
+
TERRACOTTA = "#b7653c"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def render_node(node: dict, depth: int = 0) -> str:
|
| 24 |
+
margin = depth * 22
|
| 25 |
+
children = "".join(render_node(child, depth + 1) for child in node.get("children", []))
|
| 26 |
+
status = node.get("status", "unknown")
|
| 27 |
+
checkpoint = node.get("checkpoint_step") or "not selected yet"
|
| 28 |
+
cycle = "<div class='warn'>Cycle detected — recursion stopped.</div>" if node.get("cycle_detected") else ""
|
| 29 |
+
return f"""
|
| 30 |
+
<div class="lineage-card" style="margin-left:{margin}px">
|
| 31 |
+
<div class="card-top">
|
| 32 |
+
<strong>{html.escape(node.get('cultural_source') or node['lora_id'])}</strong>
|
| 33 |
+
<span class="pill">{node['weight_pct']:g}%</span>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="muted">{html.escape(node['lora_id'])} · {html.escape(str(node.get('type')))} · {html.escape(str(status))}</div>
|
| 36 |
+
<div class="muted">checkpoint: {html.escape(str(checkpoint))}</div>
|
| 37 |
+
<a href="https://huggingface.co/{html.escape(node.get('hf_repo') or '')}" target="_blank">{html.escape(node.get('hf_repo') or '')}</a>
|
| 38 |
+
{cycle}
|
| 39 |
+
</div>
|
| 40 |
+
{children}
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def render_lineage(provenance: dict) -> str:
|
| 45 |
+
ancestry = provenance.get("ancestry", [])
|
| 46 |
+
bars = []
|
| 47 |
+
colors = [SAGE, TERRACOTTA, "#3f5f8f", "#9a7b33", "#72517e"]
|
| 48 |
+
for i, node in enumerate(ancestry):
|
| 49 |
+
bars.append(
|
| 50 |
+
f"<div title='{html.escape(node['lora_id'])}' style='width:{node['weight_pct']}%;background:{colors[i % len(colors)]}'>{node['weight_pct']:g}%</div>"
|
| 51 |
+
)
|
| 52 |
+
cards = "".join(render_node(node) for node in ancestry)
|
| 53 |
+
raw = html.escape(json.dumps(provenance, indent=2))
|
| 54 |
+
sentence = html.escape(provenance_sentence(provenance))
|
| 55 |
+
full_prompt = html.escape(provenance.get("full_prompt", ""))
|
| 56 |
+
model = html.escape(provenance.get("inference_model", provenance.get("base_model", "")))
|
| 57 |
+
return f"""
|
| 58 |
+
<style>
|
| 59 |
+
.vl-wrap {{ background:{CREAM}; border:1px solid #d9cfbd; border-radius:18px; padding:18px; color:#263322; }}
|
| 60 |
+
.stack {{ display:flex; height:38px; overflow:hidden; border-radius:999px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); margin:12px 0 18px; }}
|
| 61 |
+
.stack div {{ color:white; font-weight:700; display:flex; align-items:center; justify-content:center; min-width:48px; }}
|
| 62 |
+
.lineage-card {{ background:white; border:1px solid #ddd2c0; border-radius:14px; padding:12px; margin-top:10px; }}
|
| 63 |
+
.card-top {{ display:flex; justify-content:space-between; gap:12px; }}
|
| 64 |
+
.pill {{ background:{SAGE}; color:white; padding:3px 10px; border-radius:999px; font-size:12px; }}
|
| 65 |
+
.muted {{ color:#66705f; font-size:13px; margin-top:3px; }}
|
| 66 |
+
.warn {{ color:#9b341f; font-weight:700; margin-top:6px; }}
|
| 67 |
+
details {{ margin-top:16px; }}
|
| 68 |
+
pre {{ white-space:pre-wrap; font-size:12px; background:#fff; padding:12px; border-radius:10px; }}
|
| 69 |
+
</style>
|
| 70 |
+
<div class="vl-wrap">
|
| 71 |
+
<h3>Visual lineage</h3>
|
| 72 |
+
<p>{sentence}</p>
|
| 73 |
+
<div class="muted">Model: {model}</div>
|
| 74 |
+
<div class="muted">Full prompt: {full_prompt}</div>
|
| 75 |
+
<div class="stack">{''.join(bars)}</div>
|
| 76 |
+
{cards}
|
| 77 |
+
<details><summary>Raw provenance JSON</summary><pre>{raw}</pre></details>
|
| 78 |
+
</div>
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def generate(lora_a: str, lora_b: str, blend_a: int, prompt: str, seed: int, size: int):
|
| 83 |
+
if not prompt.strip():
|
| 84 |
+
raise gr.Error("Give the image a prompt first.")
|
| 85 |
+
weight_a = blend_a / 100
|
| 86 |
+
weight_b = 1 - weight_a
|
| 87 |
+
try:
|
| 88 |
+
result = compose(
|
| 89 |
+
lora_ids=[lora_a, lora_b],
|
| 90 |
+
weights=[weight_a, weight_b],
|
| 91 |
+
prompt=prompt,
|
| 92 |
+
seed=int(seed),
|
| 93 |
+
registry_path=str(ROOT / "registry/loras.json"),
|
| 94 |
+
output_dir=str(ROOT / "outputs"),
|
| 95 |
+
width=int(size),
|
| 96 |
+
height=int(size),
|
| 97 |
+
)
|
| 98 |
+
return result["image"], render_lineage(result["provenance"])
|
| 99 |
+
except Exception as e:
|
| 100 |
+
traceback.print_exc()
|
| 101 |
+
raise gr.Error(f"Generation failed: {type(e).__name__}: {e}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def build_demo():
|
| 105 |
+
choices = lora_choices(ROOT / "registry/loras.json")
|
| 106 |
+
default_a = "eritrean_krar_v1" if any(v == "eritrean_krar_v1" for _, v in choices) else choices[0][1]
|
| 107 |
+
default_b = default_a
|
| 108 |
+
with gr.Blocks(title="Visual Lineage", theme=gr.themes.Soft(primary_hue="green")) as demo:
|
| 109 |
+
gr.Markdown(
|
| 110 |
+
"# Visual Lineage\n"
|
| 111 |
+
"*A 23andMe for imagined instruments.*\n\n"
|
| 112 |
+
"Live FLUX.2 [klein] inference with published LoRA provenance. "
|
| 113 |
+
"The first root node is `eritrean_krar_v1`, trained from openly licensed krar / bowl-lyre imagery."
|
| 114 |
+
)
|
| 115 |
+
with gr.Row():
|
| 116 |
+
with gr.Column(scale=1):
|
| 117 |
+
lora_a = gr.Dropdown(label="Instrument/source A", choices=choices, value=default_a)
|
| 118 |
+
lora_b = gr.Dropdown(label="Instrument/source B", choices=choices, value=default_b)
|
| 119 |
+
blend = gr.Slider(0, 100, value=100, step=1, label="Blend: A % / B %")
|
| 120 |
+
prompt = gr.Textbox(label="Describe the imagined instrument image", value="a musician holding a newly invented bowl-shaped string instrument in a small warm room", lines=3)
|
| 121 |
+
seed = gr.Number(label="Seed", value=42, precision=0)
|
| 122 |
+
size = gr.Dropdown(label="Image size", choices=[512, 768, 1024], value=768)
|
| 123 |
+
btn = gr.Button("Generate with live LoRA", variant="primary")
|
| 124 |
+
with gr.Column(scale=2):
|
| 125 |
+
output_image = gr.Image(label="Generated image", type="pil")
|
| 126 |
+
lineage_panel = gr.HTML(label="Visual lineage")
|
| 127 |
+
btn.click(generate, [lora_a, lora_b, blend, prompt, seed, size], [output_image, lineage_panel], show_progress="full")
|
| 128 |
+
return demo
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
build_demo().launch()
|
compose/__init__.py
ADDED
|
File without changes
|
compose/merge.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from compose.provenance import build_provenance, load_registry, lora_map, normalize_weights
|
| 7 |
+
|
| 8 |
+
BASE_INFERENCE_MODEL = "black-forest-labs/FLUX.2-klein-4B"
|
| 9 |
+
|
| 10 |
+
_PIPE = None
|
| 11 |
+
_LOADED_ADAPTERS: set[str] = set()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _device():
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
if torch.cuda.is_available():
|
| 18 |
+
return "cuda", torch.bfloat16
|
| 19 |
+
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
| 20 |
+
return "mps", torch.float16
|
| 21 |
+
return "cpu", torch.float32
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_pipe():
|
| 25 |
+
global _PIPE
|
| 26 |
+
if _PIPE is not None:
|
| 27 |
+
return _PIPE
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
from diffusers import Flux2KleinPipeline
|
| 31 |
+
|
| 32 |
+
device, dtype = _device()
|
| 33 |
+
pipe = Flux2KleinPipeline.from_pretrained(
|
| 34 |
+
BASE_INFERENCE_MODEL,
|
| 35 |
+
torch_dtype=dtype,
|
| 36 |
+
token=None,
|
| 37 |
+
)
|
| 38 |
+
pipe = pipe.to(device)
|
| 39 |
+
_PIPE = pipe
|
| 40 |
+
return pipe
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _load_adapters(pipe, lora_ids: list[str], registry_by_id: dict[str, dict[str, Any]]) -> None:
|
| 44 |
+
for lora_id in lora_ids:
|
| 45 |
+
if lora_id in _LOADED_ADAPTERS:
|
| 46 |
+
continue
|
| 47 |
+
repo = registry_by_id[lora_id]["hf_repo"]
|
| 48 |
+
pipe.load_lora_weights(repo, adapter_name=lora_id)
|
| 49 |
+
_LOADED_ADAPTERS.add(lora_id)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def compose(
|
| 53 |
+
lora_ids: list[str],
|
| 54 |
+
weights: list[float],
|
| 55 |
+
prompt: str,
|
| 56 |
+
registry_path: str = "registry/loras.json",
|
| 57 |
+
seed: int = 42,
|
| 58 |
+
output_dir: str = "outputs",
|
| 59 |
+
mode: str = "live",
|
| 60 |
+
width: int = 768,
|
| 61 |
+
height: int = 768,
|
| 62 |
+
num_inference_steps: int = 4,
|
| 63 |
+
guidance_scale: float = 1.0,
|
| 64 |
+
) -> dict[str, Any]:
|
| 65 |
+
"""Generate with FLUX.2 klein + LoRA adapters and return image/provenance.
|
| 66 |
+
|
| 67 |
+
`mode="live"` is the real path. `mode="mock"` was removed from the UI, but
|
| 68 |
+
kept as an explicit developer escape hatch only through NotImplementedError
|
| 69 |
+
so accidental demo mocks fail loudly.
|
| 70 |
+
"""
|
| 71 |
+
if mode != "live":
|
| 72 |
+
raise NotImplementedError("Mock generation is disabled. Use mode='live'.")
|
| 73 |
+
|
| 74 |
+
import torch
|
| 75 |
+
|
| 76 |
+
if len(lora_ids) != len(weights):
|
| 77 |
+
raise ValueError("lora_ids and weights must be the same length")
|
| 78 |
+
if not prompt.strip():
|
| 79 |
+
raise ValueError("Prompt is required")
|
| 80 |
+
|
| 81 |
+
registry = load_registry(registry_path)
|
| 82 |
+
by_id = lora_map(registry)
|
| 83 |
+
missing = [lid for lid in lora_ids if lid not in by_id]
|
| 84 |
+
if missing:
|
| 85 |
+
raise KeyError(f"Unknown LoRA ids: {missing}")
|
| 86 |
+
|
| 87 |
+
weights = normalize_weights(weights)
|
| 88 |
+
pipe = _load_pipe()
|
| 89 |
+
_load_adapters(pipe, lora_ids, by_id)
|
| 90 |
+
pipe.set_adapters(lora_ids, adapter_weights=weights)
|
| 91 |
+
|
| 92 |
+
triggers = " ".join(by_id[lid]["trigger"] for lid in lora_ids)
|
| 93 |
+
full_prompt = f"{triggers}. {prompt.strip()}"
|
| 94 |
+
|
| 95 |
+
device, _ = _device()
|
| 96 |
+
generator = torch.Generator(device=device if device == "cuda" else "cpu").manual_seed(int(seed))
|
| 97 |
+
result = pipe(
|
| 98 |
+
prompt=full_prompt,
|
| 99 |
+
num_inference_steps=int(num_inference_steps),
|
| 100 |
+
guidance_scale=float(guidance_scale),
|
| 101 |
+
height=int(height),
|
| 102 |
+
width=int(width),
|
| 103 |
+
generator=generator,
|
| 104 |
+
)
|
| 105 |
+
image = result.images[0]
|
| 106 |
+
|
| 107 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 108 |
+
generation_id = f"{seed}_{'_'.join(lora_ids)}"
|
| 109 |
+
output_path = Path(output_dir) / f"{generation_id}.png"
|
| 110 |
+
image.save(output_path)
|
| 111 |
+
|
| 112 |
+
provenance = build_provenance(lora_ids, weights, registry, prompt, seed, str(output_path))
|
| 113 |
+
provenance["full_prompt"] = full_prompt
|
| 114 |
+
provenance["inference_model"] = BASE_INFERENCE_MODEL
|
| 115 |
+
provenance["mode"] = "live"
|
| 116 |
+
return {"image": image, "provenance": provenance, "output_path": str(output_path)}
|
compose/provenance.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
BASE_MODEL = "black-forest-labs/FLUX.2-klein-base-4B"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_registry(registry_path: str | Path = "registry/loras.json") -> dict[str, Any]:
|
| 14 |
+
with open(registry_path, "r", encoding="utf-8") as f:
|
| 15 |
+
return json.load(f)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def lora_map(registry: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 19 |
+
return {entry["id"]: entry for entry in registry.get("loras", [])}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def expand_ancestry(
|
| 23 |
+
lora_id: str,
|
| 24 |
+
weight: float,
|
| 25 |
+
registry_by_id: dict[str, dict[str, Any]],
|
| 26 |
+
*,
|
| 27 |
+
max_depth: int = 8,
|
| 28 |
+
_depth: int = 0,
|
| 29 |
+
_seen: set[str] | None = None,
|
| 30 |
+
) -> dict[str, Any]:
|
| 31 |
+
"""Expand a LoRA node into a provenance tree.
|
| 32 |
+
|
| 33 |
+
Defaults to 8 levels: effectively complete for the hackathon, but bounded.
|
| 34 |
+
Circular references are marked instead of recursing forever.
|
| 35 |
+
"""
|
| 36 |
+
seen = set() if _seen is None else set(_seen)
|
| 37 |
+
if lora_id not in registry_by_id:
|
| 38 |
+
raise KeyError(f"Unknown LoRA id: {lora_id}")
|
| 39 |
+
|
| 40 |
+
lora = registry_by_id[lora_id]
|
| 41 |
+
node = {
|
| 42 |
+
"lora_id": lora["id"],
|
| 43 |
+
"weight": round(float(weight), 4),
|
| 44 |
+
"weight_pct": round(float(weight) * 100, 2),
|
| 45 |
+
"creator": lora.get("creator"),
|
| 46 |
+
"cultural_source": lora.get("cultural_source"),
|
| 47 |
+
"hf_repo": lora.get("hf_repo"),
|
| 48 |
+
"checkpoint_step": lora.get("checkpoint_step"),
|
| 49 |
+
"type": lora.get("type"),
|
| 50 |
+
"parent_ids": lora.get("parent_ids", []),
|
| 51 |
+
"status": lora.get("status", "unknown"),
|
| 52 |
+
"children": [],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if lora_id in seen:
|
| 56 |
+
node["cycle_detected"] = True
|
| 57 |
+
return node
|
| 58 |
+
if _depth >= max_depth:
|
| 59 |
+
node["max_depth_reached"] = True
|
| 60 |
+
return node
|
| 61 |
+
|
| 62 |
+
seen.add(lora_id)
|
| 63 |
+
parent_ids = lora.get("parent_ids", [])
|
| 64 |
+
if parent_ids:
|
| 65 |
+
parent_weight = float(weight) / len(parent_ids)
|
| 66 |
+
node["children"] = [
|
| 67 |
+
expand_ancestry(
|
| 68 |
+
parent_id,
|
| 69 |
+
parent_weight,
|
| 70 |
+
registry_by_id,
|
| 71 |
+
max_depth=max_depth,
|
| 72 |
+
_depth=_depth + 1,
|
| 73 |
+
_seen=seen,
|
| 74 |
+
)
|
| 75 |
+
for parent_id in parent_ids
|
| 76 |
+
if parent_id in registry_by_id
|
| 77 |
+
]
|
| 78 |
+
return node
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def normalize_weights(weights: list[float]) -> list[float]:
|
| 82 |
+
total = sum(weights)
|
| 83 |
+
if total <= 0:
|
| 84 |
+
raise ValueError("Blend weights must sum to a positive number")
|
| 85 |
+
return [w / total for w in weights]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def file_sha256(path: str | Path | None) -> str | None:
|
| 89 |
+
if path is None:
|
| 90 |
+
return None
|
| 91 |
+
p = Path(path)
|
| 92 |
+
if not p.exists() or not p.is_file():
|
| 93 |
+
return None
|
| 94 |
+
h = hashlib.sha256()
|
| 95 |
+
with p.open("rb") as f:
|
| 96 |
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
| 97 |
+
h.update(chunk)
|
| 98 |
+
return "sha256:" + h.hexdigest()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build_provenance(
|
| 102 |
+
lora_ids: list[str],
|
| 103 |
+
weights: list[float],
|
| 104 |
+
registry: dict[str, Any],
|
| 105 |
+
prompt: str,
|
| 106 |
+
seed: int,
|
| 107 |
+
output_path: str | None = None,
|
| 108 |
+
*,
|
| 109 |
+
max_depth: int = 8,
|
| 110 |
+
) -> dict[str, Any]:
|
| 111 |
+
if len(lora_ids) != len(weights):
|
| 112 |
+
raise ValueError("lora_ids and weights must be the same length")
|
| 113 |
+
|
| 114 |
+
weights = normalize_weights(weights)
|
| 115 |
+
by_id = lora_map(registry)
|
| 116 |
+
ancestry = [
|
| 117 |
+
expand_ancestry(lora_id, weight, by_id, max_depth=max_depth)
|
| 118 |
+
for lora_id, weight in zip(lora_ids, weights)
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"generation_id": str(uuid.uuid4()),
|
| 123 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 124 |
+
"prompt": prompt,
|
| 125 |
+
"seed": seed,
|
| 126 |
+
"base_model": BASE_MODEL,
|
| 127 |
+
"ancestry": ancestry,
|
| 128 |
+
"image_hash": file_sha256(output_path),
|
| 129 |
+
"output_path": output_path,
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def provenance_sentence(provenance: dict[str, Any]) -> str:
|
| 134 |
+
parts = [
|
| 135 |
+
f"{node['weight_pct']:g}% {node['cultural_source']}"
|
| 136 |
+
for node in provenance.get("ancestry", [])
|
| 137 |
+
]
|
| 138 |
+
if not parts:
|
| 139 |
+
return "No lineage data available yet."
|
| 140 |
+
return "This image blends " + " and ".join(parts) + "."
|
registry/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def registry_path() -> Path:
|
| 9 |
+
return Path(__file__).with_name("loras.json")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_registry(path: str | Path | None = None) -> dict[str, Any]:
|
| 13 |
+
p = Path(path) if path else registry_path()
|
| 14 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def lora_choices(path: str | Path | None = None) -> list[tuple[str, str]]:
|
| 18 |
+
registry = load_registry(path)
|
| 19 |
+
choices = []
|
| 20 |
+
for lora in registry.get("loras", []):
|
| 21 |
+
label = f"{lora['cultural_source']} · {lora['id']} ({lora.get('status', 'unknown')})"
|
| 22 |
+
choices.append((label, lora["id"]))
|
| 23 |
+
return choices
|
registry/loras.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"loras": [
|
| 3 |
+
{
|
| 4 |
+
"id": "eritrean_krar_v1",
|
| 5 |
+
"trigger": "ERTRN_KRAR",
|
| 6 |
+
"hf_repo": "build-small-hackathon/visual-lineage-eritrean_krar_v1",
|
| 7 |
+
"checkpoint_step": 1500,
|
| 8 |
+
"type": "instrument",
|
| 9 |
+
"cultural_source": "Eritrean krar / Horn of Africa bowl lyre",
|
| 10 |
+
"creator": "projectmehari",
|
| 11 |
+
"parent_ids": [],
|
| 12 |
+
"status": "published",
|
| 13 |
+
"sensitivity": {
|
| 14 |
+
"requires_review": false
|
| 15 |
+
},
|
| 16 |
+
"local_artifact": "train/output/eritrean_krar_v1_fresh_20260614-111947/eritrean_krar_v1/eritrean_krar_v1.safetensors",
|
| 17 |
+
"sample_review": "Only valid generated samples downloaded for steps 1250/1500; final selected provisionally due deadline."
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "eritrean_heritage_v1",
|
| 21 |
+
"trigger": "ERTRN_HRTG",
|
| 22 |
+
"hf_repo": "projectmehari/visual-lineage-eritrean_heritage_v1",
|
| 23 |
+
"checkpoint_step": null,
|
| 24 |
+
"type": "heritage",
|
| 25 |
+
"cultural_source": "Eritrean visual heritage",
|
| 26 |
+
"creator": "projectmehari",
|
| 27 |
+
"parent_ids": [],
|
| 28 |
+
"status": "planned"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "mtl_514_v1",
|
| 32 |
+
"trigger": "MTL_514",
|
| 33 |
+
"hf_repo": "projectmehari/visual-lineage-mtl_514_v1",
|
| 34 |
+
"checkpoint_step": null,
|
| 35 |
+
"type": "meme",
|
| 36 |
+
"cultural_source": "Montr\u00e9al 514 street culture",
|
| 37 |
+
"creator": "projectmehari",
|
| 38 |
+
"parent_ids": [],
|
| 39 |
+
"status": "planned"
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"id": "knicks_in_4_v1",
|
| 43 |
+
"trigger": "KNKS_N4",
|
| 44 |
+
"hf_repo": "projectmehari/visual-lineage-knicks_in_4_v1",
|
| 45 |
+
"checkpoint_step": null,
|
| 46 |
+
"type": "meme",
|
| 47 |
+
"cultural_source": "Knicks playoff fan culture",
|
| 48 |
+
"creator": "projectmehari",
|
| 49 |
+
"parent_ids": [],
|
| 50 |
+
"status": "planned"
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "habesha_diaspora_v1",
|
| 54 |
+
"trigger": "HBSH_DSPR",
|
| 55 |
+
"hf_repo": "projectmehari/visual-lineage-habesha_diaspora_v1",
|
| 56 |
+
"checkpoint_step": null,
|
| 57 |
+
"type": "heritage",
|
| 58 |
+
"cultural_source": "Habesha diaspora visual culture",
|
| 59 |
+
"creator": "projectmehari",
|
| 60 |
+
"parent_ids": [],
|
| 61 |
+
"status": "planned",
|
| 62 |
+
"sensitivity": {
|
| 63 |
+
"requires_review": true
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
]
|
| 67 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0
|
| 2 |
+
pydantic>=2.0
|
| 3 |
+
PyYAML>=6.0
|
| 4 |
+
Pillow>=10.0
|
| 5 |
+
requests>=2.31
|
| 6 |
+
huggingface_hub>=0.26
|
| 7 |
+
peft>=0.14
|
| 8 |
+
accelerate>=1.0
|
| 9 |
+
torch
|
| 10 |
+
diffusers @ git+https://github.com/huggingface/diffusers.git
|
| 11 |
+
transformers
|
| 12 |
+
sentencepiece
|
| 13 |
+
protobuf
|
| 14 |
+
safetensors
|