Yidhar Claude Sonnet 4.6 commited on
Commit
2d4351a
·
1 Parent(s): 8a9175a

v18 — natural-language captions, think-label sidecars, parquet support

Browse files

Three changes that move the trainer from "hard-coded style trigger
prepended at training time" to "style anchor lives inside the natural
language caption + matched think-mode reasoning":

1. **Caption + think in a single .txt** (compact format):
caption_paragraph
---think---
6-section reasoning_paragraph
Parsed by `parse_caption_and_think()`. Caption-only files (no marker)
stay valid. Legacy split-format with `<id>.think.txt` is still read
when no marker is found, so existing v18 datasets work without
migration.

2. **`<think>` injection in the prompt template**: when `T2ISample.think`
is non-empty, `SenseNovaU1Collator` renders it inside the otherwise-
empty `<think></think>` block of the official chat template. This
matches the inference distribution when sampling with `--think-mode`,
where the model autoregressively fills the same window — fixes the
"noise output at sample time after train didn't see filled-think"
class of failure.

3. **`ArrowT2IDataset`** for 1M-scale runs: parquet shards with schema
`(sample_id, caption, think?, image bytes or path)`. Trainer
auto-dispatches based on `data.data_dir` suffix. New CLI:
python -m train_u1.scripts.dataset_tools pack-arrow FOLDER --out OUT.parquet
python -m train_u1.scripts.dataset_tools inspect-arrow OUT.parquet
python -m train_u1.scripts.dataset_tools unify-txt FOLDER # fold .txt + .think.txt → unified .txt

`configs/v18.yaml` ships the new recipe (empty trigger, conservative
VRAM settings for the longer prefix from think labels).

Adds `pyarrow>=14` to deps. 10 new tests in `test_dataset_format.py`
cover the parser, both legacy + unified PairedFolderT2IDataset paths,
and an arrow round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

README.md CHANGED
@@ -74,7 +74,9 @@ HF_HOME=$PWD/hf_cache python -m train_u1.scripts.install_modeling_into_snapshot
74
 
75
  ## Train
76
 
77
- 1. Lay out your data:
 
 
78
 
79
  ```
80
  dataset/my_style/
@@ -83,9 +85,44 @@ HF_HOME=$PWD/hf_cache python -m train_u1.scripts.install_modeling_into_snapshot
83
  └── … └── …
84
  ```
85
 
86
- Each `.txt` is a plain-text caption. The style trigger string (`my style`,
87
- `hayateluc style`, …) is **prepended** at training time — don't bake it
88
- into the captions yourself.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  2. Edit `configs/default.yaml`. The only fields you must touch:
91
 
 
74
 
75
  ## Train
76
 
77
+ 1. Lay out your data. Two formats are supported.
78
+
79
+ **Folder of paired files** (recommended for ≤ ~10k images):
80
 
81
  ```
82
  dataset/my_style/
 
85
  └── … └── …
86
  ```
87
 
88
+ Each `.txt` is a single-paragraph natural-language caption. Embed the
89
+ artist credit / style anchor inside the description naturally — don't
90
+ rely on a hard-coded trigger prepend (`style.trigger` in the YAML is
91
+ for backward compat only; the v18 recipe uses an empty trigger).
92
+
93
+ **Optional**: append a `<think>...</think>` reasoning label inside the
94
+ same `.txt` after a `---think---` delimiter line:
95
+
96
+ ```
97
+ An illustration by Hayateluc depicting a wisteria-trellis path under
98
+ morning glow, painterly composition, no people.
99
+ ---think---
100
+ 1. **Instruction Understanding:** ...
101
+ 6. **Explicit Prompt:** ...
102
+ ```
103
+
104
+ When present, the trainer renders this into the prompt template's
105
+ `<think>` window so train-time distribution matches inference
106
+ `--think-mode` (avoids prefix-distribution shift on long autoregressive
107
+ think). For batch generation of think labels see Agent B's prompt in
108
+ the v18 commit history; or write them yourself in the upstream
109
+ 6-section format.
110
+
111
+ **Parquet/arrow shards** (recommended for ≥ ~10k images, e.g. 1M
112
+ scaling):
113
+
114
+ ```bash
115
+ # Pack a folder dataset → single parquet shard
116
+ python -m train_u1.scripts.dataset_tools pack-arrow dataset/my_style \
117
+ --out artifacts/my_style.parquet
118
+ # Inspect first 3 rows
119
+ python -m train_u1.scripts.dataset_tools inspect-arrow artifacts/my_style.parquet
120
+ ```
121
+
122
+ Schema: `sample_id, caption, think (nullable), image (binary)`. Set
123
+ `data.data_dir` in the YAML to point at the parquet path; the training
124
+ script auto-detects `.parquet` and uses `ArrowT2IDataset` instead of
125
+ `PairedFolderT2IDataset`.
126
 
127
  2. Edit `configs/default.yaml`. The only fields you must touch:
128
 
configs/v18.yaml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v18 — natural-language captions + per-sample think labels.
2
+ #
3
+ # Drops the hard-coded `style.trigger: "hayateluc style, "` prepend that v16c
4
+ # was using, in favour of:
5
+ # - rewritten captions in `dataset/Hayateluc/<id>.txt` that embed the
6
+ # "Hayateluc / in the style of Hayateluc" anchor naturally inside the
7
+ # description (varied across the 56 samples)
8
+ # - per-sample `<id>.think.txt` sidecars containing a 6-section reasoning
9
+ # block whose section 5 explicitly anchors "Hayateluc's painterly anime
10
+ # style". The collator injects this into the prompt template's empty
11
+ # `<think>...</think>` window so train- and infer-with-think distributions
12
+ # match.
13
+ #
14
+ # Starts from the same trainable surface as v16c (LoRA attn+mlp r=64 +
15
+ # ts/ns/vision/fm_head full-FT). Re-trained from scratch since the prefix
16
+ # distribution changed — old v16c LoRA isn't a useful warm-start.
17
+
18
+ run_name: v18
19
+
20
+ data:
21
+ data_dir: dataset/Hayateluc
22
+ cap_max_pixels: 4194304
23
+ snap_bucket: true
24
+
25
+ style:
26
+ trigger: "" # ← drop the hard-coded prepend
27
+ prompt_template: official
28
+
29
+ lora:
30
+ spec: "attn=r64a64;mlp=r64a64"
31
+ dropout: 0.0
32
+
33
+ unfreeze:
34
+ - '^fm_modules\.timestep_embedder\.'
35
+ - '^fm_modules\.noise_scale_embedder\.'
36
+ - '^fm_modules\.vision_model_mot_gen\.'
37
+ - '^fm_modules\.fm_head\.'
38
+
39
+ train:
40
+ steps: 6000
41
+ lr: 5.0e-5
42
+ seed: 0
43
+ shuffle: true
44
+ grad_accum: 1
45
+ checkpoint_every: 600
46
+
47
+ runtime:
48
+ # think labels add ~320 tokens per sample's prefix → 56 × 320 × 172KB ≈ +3 GB
49
+ # of prefix KV. With gc_skip_last=6's activation residency stack (which used
50
+ # to fit at v16c's shorter prefix) this OOMs at ~30 GB on a 32 GB card.
51
+ # Drop both: stream KVs from CPU per-step + full GC on all decoder layers.
52
+ # Tradeoff is ~30% slower per step, but stable.
53
+ keep_kvs_on_gpu: false
54
+ gc_skip_last: 0
55
+ device: cuda
56
+ cpu_device: cpu
pyproject.toml CHANGED
@@ -49,6 +49,7 @@ dependencies = [
49
  "peft>=0.13",
50
  "pyyaml",
51
  "pydantic>=2.0",
 
52
  ]
53
 
54
  [project.optional-dependencies]
 
49
  "peft>=0.13",
50
  "pyyaml",
51
  "pydantic>=2.0",
52
+ "pyarrow>=14",
53
  ]
54
 
55
  [project.optional-dependencies]
requirements.txt CHANGED
@@ -20,9 +20,10 @@ numpy
20
  bitsandbytes>=0.45 # 8-bit / 4-bit base + paged AdamW8bit
21
  peft>=0.13 # not strictly required (we have our own LoRA), pulled in for utility helpers
22
 
23
- # config
24
  pyyaml
25
  pydantic>=2.0
 
26
 
27
  # tests
28
  pytest>=7
 
20
  bitsandbytes>=0.45 # 8-bit / 4-bit base + paged AdamW8bit
21
  peft>=0.13 # not strictly required (we have our own LoRA), pulled in for utility helpers
22
 
23
+ # config + dataset
24
  pyyaml
25
  pydantic>=2.0
26
+ pyarrow>=14 # ArrowT2IDataset (parquet shards) for 1M+ scale datasets
27
 
28
  # tests
29
  pytest>=7
train_u1/data/collators.py CHANGED
@@ -204,14 +204,28 @@ class SenseNovaU1Collator:
204
  else:
205
  raw_prompts = [s.prompt for s in samples]
206
  if self._build_t2i_query is not None:
207
- prompts = [
208
- self._build_t2i_query(
209
- rp,
210
- system_message=self._sys_msg_for_gen,
211
- append_text=self._gen_append,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  )
213
- for rp in raw_prompts
214
- ]
215
  else:
216
  prompts = list(raw_prompts)
217
  input_ids, text_lens = self._tokenize(prompts)
 
204
  else:
205
  raw_prompts = [s.prompt for s in samples]
206
  if self._build_t2i_query is not None:
207
+ prompts = []
208
+ for rp, s in zip(raw_prompts, samples):
209
+ # Per-sample think injection: when the dataset supplies a
210
+ # `think` text, render it INSIDE the otherwise-empty
211
+ # `<think></think>` block of the official prompt template.
212
+ # This makes training distribution match inference-time
213
+ # `--think-mode`, where the model autoregressively fills the
214
+ # same window with ~250-400 reasoning tokens. Without this,
215
+ # the gen tower sees an unfamiliar prefix length/content
216
+ # at inference and the LoRA delta is calibrated against
217
+ # the wrong cond-KV distribution.
218
+ if s.think:
219
+ append_text = f"<think>\n{s.think}\n</think>\n\n<img>"
220
+ else:
221
+ append_text = self._gen_append
222
+ prompts.append(
223
+ self._build_t2i_query(
224
+ rp,
225
+ system_message=self._sys_msg_for_gen,
226
+ append_text=append_text,
227
+ )
228
  )
 
 
229
  else:
230
  prompts = list(raw_prompts)
231
  input_ids, text_lens = self._tokenize(prompts)
train_u1/data/datasets.py CHANGED
@@ -32,6 +32,12 @@ class T2ISample:
32
  prompt: str
33
  image: torch.Tensor # (3, H, W) in [0, 1] or normalized — collator decides
34
  seed: int = 0
 
 
 
 
 
 
35
 
36
 
37
  class SyntheticT2ITinyDataset(Dataset):
@@ -164,13 +170,168 @@ class PairedFolderT2IDataset(Dataset):
164
 
165
  img_path, txt_path, stem = self.pairs[idx]
166
  with open(txt_path, encoding="utf-8") as f:
167
- caption = f.read().strip()
 
168
  if self.prompt_template:
169
  caption = self.prompt_template.format(caption=caption)
 
 
 
 
 
 
 
170
  chw, _hw = load_and_preprocess_image(
171
  img_path,
172
  cap_max_pixels=self.cap_max_pixels,
173
  normalize="x0", # 公开证据显示 — fm_head output space, [-1, 1] (NOT ImageNet)
174
  snap_bucket=self.snap_bucket,
175
  )
176
- return T2ISample(sample_id=stem, prompt=caption, image=chw, seed=hash(stem) & 0xFFFF)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  prompt: str
33
  image: torch.Tensor # (3, H, W) in [0, 1] or normalized — collator decides
34
  seed: int = 0
35
+ # Optional pre-computed `<think>...</think>` reasoning text. When set, the
36
+ # collator embeds it INSIDE the empty think block of the official prompt
37
+ # template, so training distribution matches inference-time `--think-mode`.
38
+ # When None, the empty `<think>\n\n</think>` block is preserved (matches
39
+ # inference-time without `--think-mode`).
40
+ think: str | None = None
41
 
42
 
43
  class SyntheticT2ITinyDataset(Dataset):
 
170
 
171
  img_path, txt_path, stem = self.pairs[idx]
172
  with open(txt_path, encoding="utf-8") as f:
173
+ raw = f.read()
174
+ caption, think_text = parse_caption_and_think(raw)
175
  if self.prompt_template:
176
  caption = self.prompt_template.format(caption=caption)
177
+ # Legacy fallback: `<id>.think.txt` separate sidecar (deprecated;
178
+ # `parse_caption_and_think` is the preferred path).
179
+ if think_text is None:
180
+ think_path = img_path.with_suffix(".think.txt")
181
+ if think_path.is_file():
182
+ with open(think_path, encoding="utf-8") as f:
183
+ think_text = f.read().strip() or None
184
  chw, _hw = load_and_preprocess_image(
185
  img_path,
186
  cap_max_pixels=self.cap_max_pixels,
187
  normalize="x0", # 公开证据显示 — fm_head output space, [-1, 1] (NOT ImageNet)
188
  snap_bucket=self.snap_bucket,
189
  )
190
+ return T2ISample(
191
+ sample_id=stem, prompt=caption, image=chw,
192
+ seed=hash(stem) & 0xFFFF, think=think_text,
193
+ )
194
+
195
+
196
+ # Marker used inside a single `.txt` to separate caption from think label.
197
+ # Any whitespace tolerated around the marker; case-insensitive on the
198
+ # `THINK` keyword. Pick a marker unlikely to appear in natural prose.
199
+ THINK_DELIMITER_RE = __import__("re").compile(r"^\s*-{3,}\s*think\s*-{3,}\s*$", flags=__import__("re").IGNORECASE | __import__("re").MULTILINE)
200
+
201
+
202
+ def parse_caption_and_think(raw: str) -> tuple[str, str | None]:
203
+ """Split a caption file into `(caption, think)` parts.
204
+
205
+ The new compact format puts both labels in a single `.txt`::
206
+
207
+ A natural-language caption embedding the artist style.
208
+ ---think---
209
+ 1. **Instruction Understanding:** ...
210
+ ...
211
+ 6. **Explicit Prompt:** ...
212
+
213
+ Falls back to "whole file is caption, no think" for plain captions.
214
+ Returns `(caption, None)` if no marker is present, else `(caption, think)`
215
+ with both stripped.
216
+ """
217
+ m = THINK_DELIMITER_RE.search(raw)
218
+ if m is None:
219
+ return raw.strip(), None
220
+ caption = raw[: m.start()].strip()
221
+ think = raw[m.end():].strip()
222
+ return caption, (think or None)
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # ArrowT2IDataset (large-scale) #
227
+ # --------------------------------------------------------------------------- #
228
+
229
+
230
+ class ArrowT2IDataset(Dataset):
231
+ """T2I dataset backed by a parquet/arrow shard.
232
+
233
+ Schema expected (rows aligned with `T2ISample` fields):
234
+ - `sample_id` : string
235
+ - `caption` : string
236
+ - `think` : string (optional; nullable column)
237
+ - `image` : binary (raw image bytes, e.g. PNG/JPEG) — preferred
238
+ OR `image_path` : string (path resolved relative to
239
+ `image_root` if relative, else absolute)
240
+
241
+ Reads via `pyarrow` table-of-shards mmap, so memory-efficient even at
242
+ millions of rows. Suitable for the 1M-image scaling experiment (task #53).
243
+
244
+ Args:
245
+ path: parquet file or directory of parquet shards
246
+ image_root: base dir to resolve relative `image_path` columns
247
+ cap_max_pixels: optional VRAM-friendly cap (passed to smart_resize)
248
+ prompt_template: optional template (matches PairedFolderT2IDataset)
249
+ snap_bucket: snap to nearest official bucket
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ path: str | os.PathLike,
255
+ *,
256
+ image_root: str | os.PathLike | None = None,
257
+ cap_max_pixels: int | None = None,
258
+ prompt_template: str | None = None,
259
+ snap_bucket: bool = False,
260
+ ):
261
+ try:
262
+ import pyarrow.parquet as pq # noqa: F401
263
+ except ImportError as e:
264
+ raise ImportError(
265
+ "ArrowT2IDataset requires pyarrow. Install with `pip install pyarrow>=14`."
266
+ ) from e
267
+ self.path = Path(path)
268
+ self.image_root = Path(image_root) if image_root else None
269
+ self.cap_max_pixels = cap_max_pixels
270
+ self.prompt_template = prompt_template
271
+ self.snap_bucket = snap_bucket
272
+ self._table = None # lazy-loaded
273
+ self._n: int | None = None
274
+
275
+ def _ensure_table(self):
276
+ if self._table is not None:
277
+ return
278
+ import pyarrow.parquet as pq
279
+ if self.path.is_dir():
280
+ # Directory of shards — read combined.
281
+ self._table = pq.read_table(str(self.path))
282
+ else:
283
+ self._table = pq.read_table(str(self.path))
284
+ cols = self._table.column_names
285
+ # Schema sanity
286
+ if "sample_id" not in cols or "caption" not in cols:
287
+ raise RuntimeError(
288
+ f"{self.path}: required columns missing. "
289
+ f"Schema must include `sample_id` and `caption`. Got: {cols}"
290
+ )
291
+ if "image" not in cols and "image_path" not in cols:
292
+ raise RuntimeError(
293
+ f"{self.path}: must have either `image` (bytes) or `image_path` (string). Got: {cols}"
294
+ )
295
+ self._n = self._table.num_rows
296
+
297
+ def __len__(self) -> int:
298
+ self._ensure_table()
299
+ return self._n # type: ignore[return-value]
300
+
301
+ def __getitem__(self, idx: int) -> T2ISample:
302
+ from io import BytesIO
303
+
304
+ from train_u1.data.u1_preprocess import (
305
+ load_and_preprocess_image,
306
+ preprocess_pil_image,
307
+ )
308
+
309
+ self._ensure_table()
310
+ row = self._table.slice(idx, 1).to_pydict()
311
+ sample_id = row["sample_id"][0]
312
+ caption = row["caption"][0]
313
+ think = (row.get("think") or [None])[0] or None
314
+
315
+ if "image" in self._table.column_names and row["image"][0] is not None:
316
+ from PIL import Image
317
+ pil = Image.open(BytesIO(row["image"][0])).convert("RGB")
318
+ chw, _hw = preprocess_pil_image(
319
+ pil, cap_max_pixels=self.cap_max_pixels,
320
+ normalize="x0", snap_bucket=self.snap_bucket,
321
+ )
322
+ else:
323
+ img_path_str = row["image_path"][0]
324
+ img_path = Path(img_path_str)
325
+ if not img_path.is_absolute() and self.image_root is not None:
326
+ img_path = self.image_root / img_path
327
+ chw, _hw = load_and_preprocess_image(
328
+ img_path, cap_max_pixels=self.cap_max_pixels,
329
+ normalize="x0", snap_bucket=self.snap_bucket,
330
+ )
331
+
332
+ if self.prompt_template:
333
+ caption = self.prompt_template.format(caption=caption)
334
+ return T2ISample(
335
+ sample_id=str(sample_id), prompt=str(caption), image=chw,
336
+ seed=hash(sample_id) & 0xFFFF, think=think,
337
+ )
train_u1/data/u1_preprocess.py CHANGED
@@ -101,8 +101,8 @@ def snap_to_official_bucket(H: int, W: int) -> tuple[int, int]:
101
  return best
102
 
103
 
104
- def load_and_preprocess_image(
105
- path: str | Path,
106
  *,
107
  factor: int = PATCH32,
108
  min_pixels: int = SMART_RESIZE_MIN_PIXELS,
@@ -111,26 +111,12 @@ def load_and_preprocess_image(
111
  normalize: str = "x0",
112
  snap_bucket: bool = False,
113
  ) -> tuple[torch.Tensor, tuple[int, int]]:
114
- """Read RGB image, smart-resize, return `(image_chw, (H, W))`.
115
-
116
- `normalize`:
117
- - `"x0"` → mean=(0.5,0.5,0.5), std=(0.5,0.5,0.5) → output in [-1, 1].
118
- Use this for FM training x0 targets (matches fm_head output
119
- space + `t2i_generate.image_prediction` Euler state).
120
- - `"vision"` → ImageNet mean/std. Use this for ordinary `vision_model`
121
- inputs (understanding path).
122
- - `"none"` → raw [0, 1] (debugging only).
123
-
124
- `cap_max_pixels` further clamps the upstream max (e.g. 4194304 for the
125
- 2048² training bucket).
126
 
127
- `snap_bucket`: if True, pick the closest aspect-ratio match in
128
- `OFFICIAL_BUCKETS_HW` (overrides smart_resize/min/max/cap). Use this for
129
- training when you want every sample to land on a known-supported bucket
130
- shape, eliminating the "trained on arbitrary shape, sampled at 2048²"
131
- mismatch.
132
  """
133
- img = Image.open(path).convert("RGB")
134
  W, H = img.size
135
  if snap_bucket:
136
  H_bar, W_bar = snap_to_official_bucket(H, W)
@@ -141,11 +127,11 @@ def load_and_preprocess_image(
141
 
142
  import numpy as np
143
 
144
- arr = np.asarray(img).astype("float32") / 255.0 # (H, W, 3) in [0, 1]
145
  if normalize == "x0":
146
  mean = np.array(X0_MEAN, dtype="float32")
147
  std = np.array(X0_STD, dtype="float32")
148
- arr = (arr - mean) / std # → [-1, 1]
149
  elif normalize == "vision":
150
  mean = np.array(IMAGENET_MEAN, dtype="float32")
151
  std = np.array(IMAGENET_STD, dtype="float32")
@@ -155,5 +141,41 @@ def load_and_preprocess_image(
155
  else:
156
  raise ValueError(f"normalize must be 'x0' / 'vision' / 'none', got {normalize!r}")
157
 
158
- chw = torch.from_numpy(arr.transpose(2, 0, 1)) # (3, H, W)
159
  return chw, (H_bar, W_bar)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  return best
102
 
103
 
104
+ def preprocess_pil_image(
105
+ img: Image.Image,
106
  *,
107
  factor: int = PATCH32,
108
  min_pixels: int = SMART_RESIZE_MIN_PIXELS,
 
111
  normalize: str = "x0",
112
  snap_bucket: bool = False,
113
  ) -> tuple[torch.Tensor, tuple[int, int]]:
114
+ """In-memory variant of `load_and_preprocess_image` taking a PIL.Image.
 
 
 
 
 
 
 
 
 
 
 
115
 
116
+ Use this for arrow/parquet datasets that decode image bytes inline.
117
+ Returns `(image_chw, (H, W))` with the same conventions.
 
 
 
118
  """
119
+ img = img.convert("RGB")
120
  W, H = img.size
121
  if snap_bucket:
122
  H_bar, W_bar = snap_to_official_bucket(H, W)
 
127
 
128
  import numpy as np
129
 
130
+ arr = np.asarray(img).astype("float32") / 255.0
131
  if normalize == "x0":
132
  mean = np.array(X0_MEAN, dtype="float32")
133
  std = np.array(X0_STD, dtype="float32")
134
+ arr = (arr - mean) / std
135
  elif normalize == "vision":
136
  mean = np.array(IMAGENET_MEAN, dtype="float32")
137
  std = np.array(IMAGENET_STD, dtype="float32")
 
141
  else:
142
  raise ValueError(f"normalize must be 'x0' / 'vision' / 'none', got {normalize!r}")
143
 
144
+ chw = torch.from_numpy(arr.transpose(2, 0, 1))
145
  return chw, (H_bar, W_bar)
146
+
147
+
148
+ def load_and_preprocess_image(
149
+ path: str | Path,
150
+ *,
151
+ factor: int = PATCH32,
152
+ min_pixels: int = SMART_RESIZE_MIN_PIXELS,
153
+ max_pixels: int = SMART_RESIZE_MAX_PIXELS,
154
+ cap_max_pixels: int | None = None,
155
+ normalize: str = "x0",
156
+ snap_bucket: bool = False,
157
+ ) -> tuple[torch.Tensor, tuple[int, int]]:
158
+ """Read RGB image, smart-resize, return `(image_chw, (H, W))`.
159
+
160
+ `normalize`:
161
+ - `"x0"` → mean=(0.5,0.5,0.5), std=(0.5,0.5,0.5) → output in [-1, 1].
162
+ Use this for FM training x0 targets (matches fm_head output
163
+ space + `t2i_generate.image_prediction` Euler state).
164
+ - `"vision"` → ImageNet mean/std. Use this for ordinary `vision_model`
165
+ inputs (understanding path).
166
+ - `"none"` → raw [0, 1] (debugging only).
167
+
168
+ `cap_max_pixels` further clamps the upstream max (e.g. 4194304 for the
169
+ 2048² training bucket).
170
+
171
+ `snap_bucket`: if True, pick the closest aspect-ratio match in
172
+ `OFFICIAL_BUCKETS_HW` (overrides smart_resize/min/max/cap). Use this for
173
+ training when you want every sample to land on a known-supported bucket
174
+ shape, eliminating the "trained on arbitrary shape, sampled at 2048²"
175
+ mismatch.
176
+ """
177
+ img = Image.open(path)
178
+ return preprocess_pil_image(
179
+ img, factor=factor, min_pixels=min_pixels, max_pixels=max_pixels,
180
+ cap_max_pixels=cap_max_pixels, normalize=normalize, snap_bucket=snap_bucket,
181
+ )
train_u1/scripts/dataset_tools.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset format conversion utilities.
2
+
3
+ Three subcommands:
4
+
5
+ unify-txt — fold legacy `<id>.txt` + `<id>.think.txt` into a single
6
+ `<id>.txt` with the `---think---` delimiter, in place.
7
+ Use after a successful training run when you want to
8
+ simplify the on-disk layout. Originals at `<id>.old.txt`
9
+ are left untouched.
10
+
11
+ pack-arrow — pack a folder dataset into a parquet shard for the
12
+ ArrowT2IDataset path. Schema written:
13
+ sample_id, caption, think (nullable), image (bytes)
14
+ Suitable for 1M-image scaling — one shard per ~10-50k images.
15
+
16
+ inspect-arrow — print row count + first 3 rows of a parquet shard for
17
+ sanity checking.
18
+
19
+ Usage:
20
+ python -m train_u1.scripts.dataset_tools unify-txt dataset/Hayateluc
21
+ python -m train_u1.scripts.dataset_tools pack-arrow dataset/Hayateluc \
22
+ --out artifacts/hayateluc.parquet
23
+ python -m train_u1.scripts.dataset_tools inspect-arrow artifacts/hayateluc.parquet
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import sys
29
+ from pathlib import Path
30
+
31
+
32
+ THINK_MARKER = "---think---"
33
+
34
+
35
+ def _read(p: Path) -> str:
36
+ with open(p, encoding="utf-8") as f:
37
+ return f.read().strip()
38
+
39
+
40
+ def cmd_unify_txt(folder: Path, *, dry_run: bool = False) -> int:
41
+ """Fold `<id>.txt` + `<id>.think.txt` → unified `<id>.txt` (in place).
42
+
43
+ Skips ids whose `<id>.txt` already contains the `---think---` marker
44
+ (idempotent). Skips ids without a corresponding `<id>.think.txt`.
45
+ """
46
+ if not folder.is_dir():
47
+ print(f"folder not found: {folder}", file=sys.stderr); return 2
48
+
49
+ n_unified = 0
50
+ n_skipped_already = 0
51
+ n_skipped_no_think = 0
52
+ for txt in sorted(folder.glob("*.txt")):
53
+ if txt.name.endswith(".old.txt") or txt.name.endswith(".think.txt"):
54
+ continue
55
+ body = _read(txt)
56
+ if THINK_MARKER in body.lower():
57
+ n_skipped_already += 1
58
+ continue
59
+ think_path = txt.with_suffix(".think.txt")
60
+ if not think_path.is_file():
61
+ n_skipped_no_think += 1
62
+ continue
63
+ think_body = _read(think_path)
64
+ unified = f"{body}\n\n{THINK_MARKER}\n{think_body}\n"
65
+ if dry_run:
66
+ print(f"[dry-run] would unify {txt.name} ({len(body)} + {len(think_body)} chars)")
67
+ else:
68
+ txt.write_text(unified, encoding="utf-8")
69
+ think_path.unlink()
70
+ n_unified += 1
71
+ print(f"unified={n_unified} already-unified={n_skipped_already} no-think={n_skipped_no_think}")
72
+ return 0
73
+
74
+
75
+ def cmd_pack_arrow(
76
+ folder: Path, out: Path, *,
77
+ image_extensions: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp"),
78
+ inline_bytes: bool = True,
79
+ ) -> int:
80
+ """Pack a paired-folder dataset into a single parquet shard."""
81
+ try:
82
+ import pyarrow as pa
83
+ import pyarrow.parquet as pq
84
+ except ImportError:
85
+ print("pyarrow required (pip install pyarrow)", file=sys.stderr); return 2
86
+
87
+ if not folder.is_dir():
88
+ print(f"folder not found: {folder}", file=sys.stderr); return 2
89
+
90
+ sample_ids: list[str] = []
91
+ captions: list[str] = []
92
+ thinks: list[str | None] = []
93
+ image_blobs: list[bytes | None] = []
94
+ image_paths: list[str | None] = []
95
+
96
+ pairs: list[tuple[Path, Path]] = []
97
+ for ext in image_extensions:
98
+ for img in sorted(folder.glob(f"*{ext}")):
99
+ txt = img.with_suffix(".txt")
100
+ if txt.is_file():
101
+ pairs.append((img, txt))
102
+ if not pairs:
103
+ print(f"no paired (image, .txt) pairs in {folder}", file=sys.stderr); return 2
104
+
105
+ for img_path, txt_path in pairs:
106
+ # Use the same parser used by PairedFolderT2IDataset
107
+ from train_u1.data.datasets import parse_caption_and_think
108
+ raw = _read(txt_path)
109
+ caption, think = parse_caption_and_think(raw)
110
+ # Legacy fallback for unmigrated data
111
+ if think is None:
112
+ tt = txt_path.with_suffix(".think.txt")
113
+ if tt.is_file():
114
+ think = _read(tt) or None
115
+ sample_ids.append(img_path.stem)
116
+ captions.append(caption)
117
+ thinks.append(think)
118
+ if inline_bytes:
119
+ with open(img_path, "rb") as f:
120
+ image_blobs.append(f.read())
121
+ image_paths.append(None)
122
+ else:
123
+ image_blobs.append(None)
124
+ image_paths.append(str(img_path.resolve()))
125
+
126
+ arrays = {
127
+ "sample_id": pa.array(sample_ids, type=pa.string()),
128
+ "caption": pa.array(captions, type=pa.string()),
129
+ "think": pa.array(thinks, type=pa.string()),
130
+ }
131
+ if inline_bytes:
132
+ arrays["image"] = pa.array(image_blobs, type=pa.binary())
133
+ else:
134
+ arrays["image_path"] = pa.array(image_paths, type=pa.string())
135
+
136
+ table = pa.table(arrays)
137
+ out.parent.mkdir(parents=True, exist_ok=True)
138
+ pq.write_table(table, str(out), compression="zstd")
139
+ size_mb = out.stat().st_size / 1e6
140
+ print(f"wrote {table.num_rows} rows → {out} ({size_mb:.1f} MB, "
141
+ f"inline_bytes={inline_bytes})")
142
+ return 0
143
+
144
+
145
+ def cmd_inspect_arrow(path: Path) -> int:
146
+ try:
147
+ import pyarrow.parquet as pq
148
+ except ImportError:
149
+ print("pyarrow required", file=sys.stderr); return 2
150
+
151
+ table = pq.read_table(str(path))
152
+ print(f"path: {path}")
153
+ print(f"rows: {table.num_rows}")
154
+ print(f"schema:")
155
+ for f in table.schema:
156
+ print(f" {f.name:<14s} {f.type}")
157
+ print(f"---first 3 rows (truncated)---")
158
+ for i in range(min(3, table.num_rows)):
159
+ row = table.slice(i, 1).to_pydict()
160
+ sid = row["sample_id"][0]
161
+ cap = row["caption"][0][:80] + "..." if len(row["caption"][0]) > 80 else row["caption"][0]
162
+ thk = row.get("think", [None])[0]
163
+ thk_str = (thk[:80] + "...") if thk and len(thk) > 80 else (thk or "<none>")
164
+ if "image" in table.column_names:
165
+ sz = len(row["image"][0]) if row["image"][0] else 0
166
+ print(f" [{i}] id={sid} cap={cap!r} image={sz/1e3:.1f} KB")
167
+ else:
168
+ print(f" [{i}] id={sid} cap={cap!r} image_path={row['image_path'][0]}")
169
+ print(f" think: {thk_str!r}")
170
+ return 0
171
+
172
+
173
+ def main() -> int:
174
+ ap = argparse.ArgumentParser(description="Dataset format conversion utilities.")
175
+ sub = ap.add_subparsers(dest="cmd", required=True)
176
+
177
+ p_unify = sub.add_parser("unify-txt", help="fold .txt + .think.txt into one .txt")
178
+ p_unify.add_argument("folder", type=Path)
179
+ p_unify.add_argument("--dry-run", action="store_true")
180
+
181
+ p_pack = sub.add_parser("pack-arrow", help="pack folder → parquet shard")
182
+ p_pack.add_argument("folder", type=Path)
183
+ p_pack.add_argument("--out", type=Path, required=True)
184
+ p_pack.add_argument("--paths-only", action="store_true",
185
+ help="store image_path instead of inline image bytes "
186
+ "(smaller parquet, but reads still hit the filesystem)")
187
+
188
+ p_inspect = sub.add_parser("inspect-arrow", help="print row count + sample rows")
189
+ p_inspect.add_argument("path", type=Path)
190
+
191
+ args = ap.parse_args()
192
+ if args.cmd == "unify-txt":
193
+ return cmd_unify_txt(args.folder, dry_run=args.dry_run)
194
+ if args.cmd == "pack-arrow":
195
+ return cmd_pack_arrow(args.folder, args.out, inline_bytes=not args.paths_only)
196
+ if args.cmd == "inspect-arrow":
197
+ return cmd_inspect_arrow(args.path)
198
+ return 1
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())
train_u1/scripts/sample_t2i_offload.py CHANGED
@@ -304,7 +304,7 @@ def t2i_generate_offload(
304
  think_text = tokenizer.decode(think_token_ids, skip_special_tokens=False)
305
  if verbose:
306
  tt = think_text.strip().replace("\n", " ")
307
- print(f"[offload] think generated +{cond_t_idx_extra} tokens: {tt[:160]!r}", flush=True)
308
  else:
309
  cond_kv, _ = model._t2i_prefix_forward(cond_ids, cond_idx, cond_attn)
310
  uncond_kv, _ = model._t2i_prefix_forward(uncond_ids, uncond_idx, uncond_attn)
 
304
  think_text = tokenizer.decode(think_token_ids, skip_special_tokens=False)
305
  if verbose:
306
  tt = think_text.strip().replace("\n", " ")
307
+ print(f"[offload] think generated +{cond_t_idx_extra} tokens (full): {tt!r}", flush=True)
308
  else:
309
  cond_kv, _ = model._t2i_prefix_forward(cond_ids, cond_idx, cond_attn)
310
  uncond_kv, _ = model._t2i_prefix_forward(uncond_ids, uncond_idx, uncond_attn)
train_u1/scripts/train_bf16_offload.py CHANGED
@@ -44,7 +44,7 @@ import torch
44
  from train_u1.config import TrainRunConfig, load_train_config
45
  from train_u1.constants import MODEL_ID, MODEL_SHA
46
  from train_u1.data.collators import CollatorConfig, SenseNovaU1Collator, to_device
47
- from train_u1.data.datasets import PairedFolderT2IDataset
48
  from train_u1.model.loader import _resolve_local_snapshot, load_neo_chat
49
  from train_u1.model.losses import fm_loss_x0
50
  from train_u1.model.lora import (
@@ -380,13 +380,23 @@ def main() -> int:
380
  trust_remote_code=True,
381
  )
382
 
383
- # dataset + collator
384
- ds = PairedFolderT2IDataset(
385
- args.data_dir,
386
- cap_max_pixels=args.cap_max_pixels,
387
- snap_bucket=args.snap_bucket,
388
- )
389
- n_use = min(args.n_samples, len(ds))
 
 
 
 
 
 
 
 
 
 
390
  samples = [ds[i] for i in range(n_use)]
391
  collator = SenseNovaU1Collator(
392
  tok,
 
44
  from train_u1.config import TrainRunConfig, load_train_config
45
  from train_u1.constants import MODEL_ID, MODEL_SHA
46
  from train_u1.data.collators import CollatorConfig, SenseNovaU1Collator, to_device
47
+ from train_u1.data.datasets import ArrowT2IDataset, PairedFolderT2IDataset
48
  from train_u1.model.loader import _resolve_local_snapshot, load_neo_chat
49
  from train_u1.model.losses import fm_loss_x0
50
  from train_u1.model.lora import (
 
380
  trust_remote_code=True,
381
  )
382
 
383
+ # dataset + collator. Auto-dispatch on data_dir suffix:
384
+ # .parquet → ArrowT2IDataset (single shard or directory)
385
+ # anything else, including a `.parquet`-less directory → PairedFolderT2IDataset
386
+ _data_path = Path(args.data_dir)
387
+ if _data_path.suffix == ".parquet" or (_data_path.is_dir() and any(_data_path.glob("*.parquet"))):
388
+ ds = ArrowT2IDataset(
389
+ _data_path,
390
+ cap_max_pixels=args.cap_max_pixels,
391
+ snap_bucket=args.snap_bucket,
392
+ )
393
+ else:
394
+ ds = PairedFolderT2IDataset(
395
+ args.data_dir,
396
+ cap_max_pixels=args.cap_max_pixels,
397
+ snap_bucket=args.snap_bucket,
398
+ )
399
+ n_use = len(ds) if args.n_samples is None else min(args.n_samples, len(ds))
400
  samples = [ds[i] for i in range(n_use)]
401
  collator = SenseNovaU1Collator(
402
  tok,
train_u1/tests/test_dataset_format.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the unified caption+think .txt format and arrow dataset."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from train_u1.data.datasets import THINK_DELIMITER_RE, parse_caption_and_think
9
+
10
+
11
+ def test_parse_plain_caption() -> None:
12
+ c, t = parse_caption_and_think("A simple caption with no marker.")
13
+ assert c == "A simple caption with no marker."
14
+ assert t is None
15
+
16
+
17
+ def test_parse_unified_format() -> None:
18
+ raw = (
19
+ "A painterly Hayateluc-style scene of a wisteria trellis.\n"
20
+ "\n"
21
+ "---think---\n"
22
+ "1. **Instruction Understanding:** The subject is a wisteria trellis...\n"
23
+ "6. **Explicit Prompt:** Render in Hayateluc's painterly anime style...\n"
24
+ )
25
+ c, t = parse_caption_and_think(raw)
26
+ assert c == "A painterly Hayateluc-style scene of a wisteria trellis."
27
+ assert t.startswith("1. **Instruction Understanding")
28
+ assert "6. **Explicit Prompt" in t
29
+
30
+
31
+ def test_parse_marker_case_insensitive() -> None:
32
+ raw = "cap\n--- THINK ---\nthink body"
33
+ c, t = parse_caption_and_think(raw)
34
+ assert c == "cap"
35
+ assert t == "think body"
36
+
37
+
38
+ def test_parse_extra_dashes() -> None:
39
+ raw = "cap\n----think----\nthink body" # 4-dash variant
40
+ c, t = parse_caption_and_think(raw)
41
+ assert c == "cap"
42
+ assert t == "think body"
43
+
44
+
45
+ def test_parse_no_marker_with_think_word() -> None:
46
+ """The literal word `think` (not on its own line, not as marker) is fine."""
47
+ raw = "A scene where the model needs to think hard about composition."
48
+ c, t = parse_caption_and_think(raw)
49
+ assert c == raw.strip()
50
+ assert t is None
51
+
52
+
53
+ def test_parse_empty_think() -> None:
54
+ raw = "cap\n---think---\n "
55
+ c, t = parse_caption_and_think(raw)
56
+ assert c == "cap"
57
+ assert t is None # whitespace-only think → None
58
+
59
+
60
+ def test_paired_folder_dataset_unified_txt(tmp_path: Path) -> None:
61
+ """PairedFolderT2IDataset reads the unified `<id>.txt` format."""
62
+ from PIL import Image
63
+
64
+ from train_u1.data.datasets import PairedFolderT2IDataset
65
+
66
+ img = Image.new("RGB", (64, 64), color=(128, 128, 128))
67
+ img.save(tmp_path / "test_001.jpg")
68
+ (tmp_path / "test_001.txt").write_text(
69
+ "An illustration by Hayateluc.\n---think---\nthink reasoning here\n"
70
+ )
71
+
72
+ ds = PairedFolderT2IDataset(tmp_path)
73
+ assert len(ds) == 1
74
+ s = ds[0]
75
+ assert s.sample_id == "test_001"
76
+ assert s.prompt == "An illustration by Hayateluc."
77
+ assert s.think == "think reasoning here"
78
+
79
+
80
+ def test_paired_folder_dataset_legacy_split(tmp_path: Path) -> None:
81
+ """Legacy split format (`<id>.txt` + `<id>.think.txt`) still works."""
82
+ from PIL import Image
83
+
84
+ from train_u1.data.datasets import PairedFolderT2IDataset
85
+
86
+ img = Image.new("RGB", (64, 64), color=(0, 0, 0))
87
+ img.save(tmp_path / "split_001.jpg")
88
+ (tmp_path / "split_001.txt").write_text("plain caption only")
89
+ (tmp_path / "split_001.think.txt").write_text("legacy think text")
90
+
91
+ ds = PairedFolderT2IDataset(tmp_path)
92
+ s = ds[0]
93
+ assert s.prompt == "plain caption only"
94
+ assert s.think == "legacy think text"
95
+
96
+
97
+ def test_arrow_dataset_roundtrip(tmp_path: Path) -> None:
98
+ """ArrowT2IDataset reads back what `dataset_tools pack-arrow` writes."""
99
+ pa = pytest.importorskip("pyarrow")
100
+ pq = pytest.importorskip("pyarrow.parquet")
101
+
102
+ from PIL import Image
103
+
104
+ from train_u1.data.datasets import ArrowT2IDataset
105
+ from train_u1.scripts.dataset_tools import cmd_pack_arrow
106
+
107
+ folder = tmp_path / "ds"
108
+ folder.mkdir()
109
+ img = Image.new("RGB", (64, 64), color=(255, 255, 255))
110
+ img.save(folder / "a.jpg")
111
+ (folder / "a.txt").write_text("cap a\n---think---\nthink a")
112
+
113
+ out = tmp_path / "out.parquet"
114
+ rc = cmd_pack_arrow(folder, out)
115
+ assert rc == 0
116
+ assert out.exists()
117
+
118
+ ds = ArrowT2IDataset(out)
119
+ assert len(ds) == 1
120
+ s = ds[0]
121
+ assert s.sample_id == "a"
122
+ assert s.prompt == "cap a"
123
+ assert s.think == "think a"
124
+ assert s.image.shape[0] == 3 # CHW
125
+
126
+
127
+ def test_think_delimiter_regex_compiled() -> None:
128
+ """Sanity: regex matches the canonical marker."""
129
+ assert THINK_DELIMITER_RE.search("cap\n---think---\nthink") is not None
130
+ assert THINK_DELIMITER_RE.search("cap\n---tHiNk---\nthink") is not None
131
+ assert THINK_DELIMITER_RE.search("cap with think word") is None