Artoria0429 commited on
Commit
617bcee
·
verified ·
1 Parent(s): 70bbedb

Upload 15 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ codabench_portrait_train_balanced_v2.json filter=lfs diff=lfs merge=lfs -text
37
+ codabench_portrait_train_multitask_v2.json filter=lfs diff=lfs merge=lfs -text
38
+ codabench_portrait_train_score_v2.json filter=lfs diff=lfs merge=lfs -text
39
+ codabench_portrait_train.json filter=lfs diff=lfs merge=lfs -text
40
+ codabench_portrait_val_multitask_v2.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Portrait Composition Aesthetics Evaluation
2
+
3
+ This project provides an inference and fusion pipeline for portrait composition aesthetics evaluation. It converts the official test set into multi-prompt prediction inputs, runs prediction with Stage2 LoRA adapters, and fuses multiple prediction files into the final `track_1_test.json`.
4
+
5
+ ## 1. Project Structure
6
+
7
+ ```text
8
+ project_root/
9
+ prepare_test_prompt_v2.py # Build multi-prompt test inputs
10
+ convert_to_answer.py # Fuse multi-path prediction results
11
+ train_track1_stage1_qwen3vl8b_v1.yaml
12
+ train_track1_stage2_qwen3vl8b_v1.yaml
13
+ qwen3vl8b_r1stage2_seed_ensemble_fast_20260511_112825/
14
+ seed20260511_stage2/ # Stage2 adapter 1
15
+ seed20260512_stage2/ # Stage2 adapter 2
16
+ datasets/
17
+ images/ # Test images
18
+ original_annotations/
19
+ track_1_test.json # Official test template
20
+ outputs/
21
+ predictions/ # Model prediction outputs
22
+ submissions/answers/ # Final submission file
23
+ ```
24
+
25
+ For path conventions, see `REPRODUCIBLE_PATHS.md`.
26
+
27
+ ## 2. Environment
28
+
29
+ Use an environment that supports Qwen3-VL and LoRA inference. LLaMA-Factory is recommended for prediction.
30
+
31
+ Basic dependencies:
32
+
33
+ ```bash
34
+ pip install torch transformers peft accelerate
35
+ ```
36
+
37
+ If you use LLaMA-Factory, install it first and make sure `llamafactory-cli` is available.
38
+
39
+ Base model:
40
+
41
+ ```text
42
+ Qwen/Qwen3-VL-8B-Instruct
43
+ ```
44
+
45
+ ## 3. Prepare Test Data
46
+
47
+ Place the official test file at:
48
+
49
+ ```text
50
+ datasets/original_annotations/track_1_test.json
51
+ ```
52
+
53
+ Place test images under:
54
+
55
+ ```text
56
+ datasets/images/
57
+ ```
58
+
59
+ Generate multi-prompt test inputs:
60
+
61
+ ```bash
62
+ python prepare_test_prompt_v2.py \
63
+ --input_json datasets/original_annotations/track_1_test.json \
64
+ --image_dir datasets/images \
65
+ --output_json outputs/predictions/track1_test_convert.json \
66
+ --prompt_variants v1,v2,v3,v4,v6,v7,v8
67
+ ```
68
+
69
+ This command generates:
70
+
71
+ ```text
72
+ outputs/predictions/track1_test_convert_v1.json
73
+ outputs/predictions/track1_test_convert_v2.json
74
+ outputs/predictions/track1_test_convert_v3.json
75
+ outputs/predictions/track1_test_convert_v4.json
76
+ outputs/predictions/track1_test_convert_v6.json
77
+ outputs/predictions/track1_test_convert_v7.json
78
+ outputs/predictions/track1_test_convert_v8.json
79
+ ```
80
+
81
+ ## 4. Run Multi-Path Prediction
82
+
83
+ Use the Stage2 adapters to run prediction on the generated prompt variants:
84
+
85
+ ```text
86
+ qwen3vl8b_r1stage2_seed_ensemble_fast_20260511_112825/seed20260511_stage2
87
+ qwen3vl8b_r1stage2_seed_ensemble_fast_20260511_112825/seed20260512_stage2
88
+ ```
89
+
90
+ If you use LLaMA-Factory, register each test JSON in `dataset_info.json`. Example:
91
+
92
+ ```json
93
+ {
94
+ "track1_test_v1": {
95
+ "file_name": "outputs/predictions/track1_test_convert_v1.json",
96
+ "formatting": "sharegpt",
97
+ "columns": {
98
+ "messages": "messages",
99
+ "images": "images"
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ Example prediction config:
106
+
107
+ ```yaml
108
+ model_name_or_path: Qwen/Qwen3-VL-8B-Instruct
109
+ adapter_name_or_path: qwen3vl8b_r1stage2_seed_ensemble_fast_20260511_112825/seed20260511_stage2
110
+ template: qwen3_vl_nothink
111
+ stage: sft
112
+ finetuning_type: lora
113
+
114
+ dataset_dir: .
115
+ eval_dataset: track1_test_v1
116
+ do_predict: true
117
+ predict_with_generate: true
118
+ cutoff_len: 4096
119
+ image_max_pixels: 786432
120
+ per_device_eval_batch_size: 8
121
+ max_new_tokens: 384
122
+ do_sample: false
123
+ top_p: 1.0
124
+ repetition_penalty: 1.02
125
+ bf16: true
126
+ flash_attn: auto
127
+
128
+ output_dir: outputs/predictions/seed20260511_v1
129
+ ```
130
+
131
+ Run prediction:
132
+
133
+ ```bash
134
+ llamafactory-cli train predict_seed20260511_v1.yaml
135
+ ```
136
+
137
+ Each prediction run should produce:
138
+
139
+ ```text
140
+ outputs/predictions/<run_name>/generated_predictions.jsonl
141
+ ```
142
+
143
+ ## 5. Fuse Prediction Results
144
+
145
+ Copy or symlink the official test template to:
146
+
147
+ ```text
148
+ outputs/predictions/track_1_test.json
149
+ ```
150
+
151
+ Fuse prediction results:
152
+
153
+ ```bash
154
+ python convert_to_answer.py \
155
+ --template_json outputs/predictions/track_1_test.json \
156
+ --predictions_jsonl \
157
+ outputs/predictions/seed20260511_v1/generated_predictions.jsonl \
158
+ outputs/predictions/seed20260511_v2/generated_predictions.jsonl \
159
+ outputs/predictions/seed20260511_v3/generated_predictions.jsonl \
160
+ outputs/predictions/seed20260511_v4/generated_predictions.jsonl \
161
+ outputs/predictions/seed20260511_v6/generated_predictions.jsonl \
162
+ outputs/predictions/seed20260511_v7/generated_predictions.jsonl \
163
+ outputs/predictions/seed20260511_v8/generated_predictions.jsonl \
164
+ --weights 1 1 1 1 1 1 1 \
165
+ --best_index 0 \
166
+ --total_score_fusion mean \
167
+ --output_json outputs/submissions/answers/track_1_test.json
168
+ ```
169
+
170
+ To fuse results from both Stage2 adapters, add all corresponding `generated_predictions.jsonl` files to `--predictions_jsonl` and provide the same number of values in `--weights`.
171
+
172
+ ## 6. Output
173
+
174
+ The final result is:
175
+
176
+ ```text
177
+ outputs/submissions/answers/track_1_test.json
178
+ ```
179
+
180
+ The file preserves the official template structure and fills in the fused:
181
+
182
+ - `criteria.level`
183
+ - `total_score`
184
+ - `answer`
185
+
186
+ ## 7. Notes
187
+
188
+ - All paths are relative to the project root.
189
+ - Image paths use the format `datasets/images/<image_name>.jpg`.
190
+ - `convert_to_answer.py` supports multi-path prediction fusion, weighted answer voting, majority voting for levels, and mean fusion for `total_score`.
191
+ - If the model output is not strict JSON, the fusion script attempts to extract the JSON snippet and answer field from text.
REPRODUCIBLE_PATHS.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reproducible Path Convention
2
+
3
+ This project no longer depends on machine-specific absolute paths. All code and data files use public model IDs or paths relative to the project root.
4
+
5
+ ## Expected Directory Layout
6
+
7
+ ```text
8
+ project_root/
9
+ codabench_portrait_train.json
10
+ codabench_portrait_val.json
11
+ prepare_dataset.py
12
+ prepare_test_prompt_v2.py
13
+ convert_to_answer.py
14
+ train_track1_stage1_qwen3vl8b_v1.yaml
15
+ train_track1_stage2_qwen3vl8b_v1.yaml
16
+ datasets/
17
+ images/
18
+ *.jpg
19
+ original_annotations/
20
+ track_1_test.json
21
+ outputs/
22
+ predictions/
23
+ submissions/
24
+ saves/
25
+ ```
26
+
27
+ ## Path Rules
28
+
29
+ - Base model: `Qwen/Qwen3-VL-8B-Instruct`
30
+ - Training and validation image paths: `datasets/images/<image_name>.jpg`
31
+ - Official test template: `outputs/predictions/track_1_test.json` or `datasets/original_annotations/track_1_test.json`
32
+ - Prediction outputs: `outputs/predictions/`
33
+ - Submission outputs: `outputs/submissions/answers/`
34
+ - LoRA adapter outputs: `saves/`
35
+ - Data generation logs: `logs/`
36
+
37
+ ## Typical Usage
38
+
39
+ Generate training data:
40
+
41
+ ```bash
42
+ python prepare_dataset.py \
43
+ --train_json codabench_portrait_train.json \
44
+ --val_json codabench_portrait_val.json \
45
+ --output_dir .
46
+ ```
47
+
48
+ Generate multi-prompt test data:
49
+
50
+ ```bash
51
+ python prepare_test_prompt_v2.py \
52
+ --input_json datasets/original_annotations/track_1_test.json \
53
+ --image_dir datasets/images \
54
+ --output_json outputs/predictions/track1_test_convert.json \
55
+ --prompt_variants v1,v2,v3,v4,v6,v7,v8
56
+ ```
57
+
58
+ Fuse prediction results:
59
+
60
+ ```bash
61
+ python convert_to_answer.py \
62
+ --template_json outputs/predictions/track_1_test.json \
63
+ --predictions_jsonl outputs/predictions/run_v1/generated_predictions.jsonl outputs/predictions/run_v2/generated_predictions.jsonl \
64
+ --output_json outputs/submissions/answers/track_1_test.json
65
+ ```
codabench_portrait_train.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ead68273d71a50331d47af49f9b1194cfe2d4755abf1fed66d7b1999745878e2
3
+ size 82860499
codabench_portrait_train_balanced_v2.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1c3bfe5b58b18736b429e8c452690e5930c636c50a39eebf23bf92d6f35c0e7
3
+ size 237557794
codabench_portrait_train_multitask_v2.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:02d06def9036381eda440c83e290bb9d73643d8e4d8beb9f975402f8f18fd478
3
+ size 96688216
codabench_portrait_train_score_v2.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f96f0445c5c5acd5a84fd2c6d029ca2317a52fc44185b4463dd0c845219f8d24
3
+ size 79931263
codabench_portrait_val.json ADDED
The diff for this file is too large to render. See raw diff
 
codabench_portrait_val_multitask_v2.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0b2556588585076c9be25afad59c0214c82cd3b2c77a51afe4768a09645fffd7
3
+ size 10742247
codabench_portrait_val_score_v2.json ADDED
The diff for this file is too large to render. See raw diff
 
convert_to_answer.py ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import copy
3
+ import json
4
+ import os
5
+ import re
6
+ from collections import Counter
7
+ from glob import glob
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+
11
+ DEFAULT_TEMPLATE_PATH = "outputs/predictions/track_1_test.json"
12
+ DEFAULT_PRED_PATH = "outputs/predictions/predict_ckpt2660/generated_predictions.jsonl"
13
+ DEFAULT_OUTPUT_PATH = "outputs/submissions/answers/track_1_test.json"
14
+
15
+
16
+ def load_json(path: str) -> Any:
17
+ with open(path, "r", encoding="utf-8") as f:
18
+ return json.load(f)
19
+
20
+
21
+ def load_jsonl(path: str) -> List[Dict[str, Any]]:
22
+ rows: List[Dict[str, Any]] = []
23
+ with open(path, "r", encoding="utf-8") as f:
24
+ for line_no, line in enumerate(f, start=1):
25
+ raw = line.strip()
26
+ if not raw:
27
+ continue
28
+ try:
29
+ rows.append(json.loads(raw))
30
+ except Exception:
31
+ print(f"[WARN] skip invalid jsonl line: {line_no}")
32
+ return rows
33
+
34
+
35
+ def normalize_level(level: Any) -> Optional[str]:
36
+ if level is None:
37
+ return None
38
+ s = str(level).strip()
39
+ if not s:
40
+ return None
41
+
42
+ m = {
43
+ "poor": "Poor",
44
+ "medium": "Medium",
45
+ "good": "Good",
46
+ "a": "A",
47
+ "b": "B",
48
+ "c": "C",
49
+ }
50
+ return m.get(s.lower(), s)
51
+
52
+
53
+ def score_to_submission_level(score: float, low_threshold: float = 5.0, high_threshold: float = 7.0) -> str:
54
+ # 比赛映射:A=Poor(0~5), B=Medium(5~7), C=Good(7~10)
55
+ if score < low_threshold:
56
+ return "A"
57
+ if score < high_threshold:
58
+ return "B"
59
+ return "C"
60
+
61
+
62
+ def to_submission_level(level: Any) -> Optional[str]:
63
+ """
64
+ Submission mapping required by Track-1 template:
65
+ A -> Poor, B -> Medium, C -> Good
66
+ """
67
+ norm = normalize_level(level)
68
+ if norm is None:
69
+ return None
70
+ m = {
71
+ "Poor": "A",
72
+ "Medium": "B",
73
+ "Good": "C",
74
+ "A": "A",
75
+ "B": "B",
76
+ "C": "C",
77
+ }
78
+ return m.get(norm)
79
+
80
+
81
+ def normalize_prediction_text(pred_row: Dict[str, Any]) -> str:
82
+ # 兼容不同推理后端字段名。
83
+ for k in ("predict", "response", "output", "text", "generation"):
84
+ if k in pred_row:
85
+ return str(pred_row.get(k, ""))
86
+ return str(pred_row)
87
+
88
+
89
+ def extract_level_from_text(pred_text: str, crit_key: str) -> Optional[str]:
90
+ pattern = rf'"{re.escape(crit_key)}"\s*:\s*\{{.*?"level"\s*:\s*"([^"]+)"'
91
+ m = re.search(pattern, pred_text, flags=re.IGNORECASE | re.DOTALL)
92
+ if not m:
93
+ return None
94
+ return to_submission_level(m.group(1))
95
+
96
+
97
+ def try_parse_predict_json(text: str) -> Optional[Dict[str, Any]]:
98
+ text = str(text or "").strip()
99
+ if not text:
100
+ return None
101
+
102
+ # 1) 直接解析
103
+ try:
104
+ obj = json.loads(text)
105
+ if isinstance(obj, dict):
106
+ return obj
107
+ except Exception:
108
+ pass
109
+
110
+ # 2) 尝试提取从首个 { 到最后一个 } 的片段
111
+ left = text.find("{")
112
+ right = text.rfind("}")
113
+ if left != -1 and right != -1 and left < right:
114
+ snippet = text[left:right + 1]
115
+ try:
116
+ obj = json.loads(snippet)
117
+ if isinstance(obj, dict):
118
+ return obj
119
+ except Exception:
120
+ pass
121
+
122
+ return None
123
+
124
+
125
+ def extract_answer(pred_obj: Optional[Dict[str, Any]], pred_text: str) -> Optional[str]:
126
+ if pred_obj is not None:
127
+ answer = str(pred_obj.get("answer", "")).strip().upper()
128
+ if answer in {"A", "B", "C", "D"}:
129
+ return answer
130
+
131
+ m = re.search(r'"answer"\s*:\s*"([A-D])"', pred_text, re.IGNORECASE)
132
+ if m:
133
+ return m.group(1).upper()
134
+
135
+ # 兼容 QA-only 推理:模型可能只输出单个字母(如 "C")。
136
+ raw = str(pred_text or "").strip().upper()
137
+ if raw:
138
+ # 情况1:整行仅有一个候选字母(允许尾随标点)。
139
+ m = re.match(r"^\s*([A-D])(?:[\.\)\]::]|\s)*$", raw)
140
+ if m:
141
+ return m.group(1)
142
+ # 情况2:短文本中只出现唯一一个 A/B/C/D,且不包含常见 JSON/选项结构。
143
+ if len(raw) <= 12 and ("{" not in raw) and ("\"" not in raw):
144
+ hits = re.findall(r"[A-D]", raw)
145
+ if len(hits) == 1:
146
+ return hits[0]
147
+ return None
148
+
149
+
150
+ def extract_total_score(pred_obj: Optional[Dict[str, Any]], pred_text: str) -> Optional[float]:
151
+ val: Optional[float] = None
152
+ if pred_obj is not None and "total_score" in pred_obj:
153
+ try:
154
+ val = float(pred_obj["total_score"])
155
+ except Exception:
156
+ val = None
157
+
158
+ if val is None:
159
+ m = re.search(r'"total_score"\s*:\s*([0-9]+(?:\.[0-9]+)?)', pred_text)
160
+ if m:
161
+ val = float(m.group(1))
162
+
163
+ if val is None:
164
+ return None
165
+
166
+ return max(0.0, min(100.0, float(val)))
167
+
168
+
169
+ def merge_total_scores(total_votes: List[float], mode: str) -> Optional[int]:
170
+ if not total_votes:
171
+ return None
172
+
173
+ vals = [float(v) for v in total_votes]
174
+ if mode == "trim_mean" and len(vals) >= 3:
175
+ vals = sorted(vals)[1:-1]
176
+ merged = sum(vals) / len(vals)
177
+ return max(0, min(100, int(round(merged))))
178
+
179
+
180
+ def parse_score_from_text(pred_text: str, crit_key: str) -> Optional[float]:
181
+ pattern = rf'"{re.escape(crit_key)}"\s*:\s*\{{.*?"score"\s*:\s*([0-9]+(?:\.[0-9]+)?)'
182
+ m = re.search(pattern, pred_text, flags=re.IGNORECASE | re.DOTALL)
183
+ if not m:
184
+ return None
185
+ try:
186
+ return float(m.group(1))
187
+ except Exception:
188
+ return None
189
+
190
+
191
+ def extract_one_criteria_level_and_score(
192
+ crit_key: str,
193
+ pred_obj: Optional[Dict[str, Any]],
194
+ pred_text: str,
195
+ ) -> Tuple[Optional[str], Optional[float]]:
196
+ score: Optional[float] = None
197
+ level: Optional[str] = None
198
+
199
+ if isinstance(pred_obj, dict):
200
+ src = pred_obj.get("criteria", {})
201
+ if isinstance(src, dict) and crit_key in src:
202
+ value = src.get(crit_key)
203
+ if isinstance(value, dict):
204
+ if "score" in value:
205
+ try:
206
+ score = float(value["score"])
207
+ except Exception:
208
+ score = None
209
+ level = to_submission_level(value.get("level"))
210
+ else:
211
+ level = to_submission_level(value)
212
+
213
+ if score is None:
214
+ score = parse_score_from_text(pred_text, crit_key)
215
+
216
+ if level is None:
217
+ level = extract_level_from_text(pred_text, crit_key)
218
+
219
+ if level not in {"A", "B", "C"}:
220
+ level = None
221
+
222
+ return level, score
223
+
224
+
225
+ def choose_majority_level(
226
+ levels: List[str],
227
+ scores: List[float],
228
+ fallback_level: Optional[str],
229
+ low_threshold: float,
230
+ high_threshold: float,
231
+ ) -> str:
232
+ if levels:
233
+ cnt = Counter(levels)
234
+ top_n = max(cnt.values())
235
+ top_levels = sorted([k for k, v in cnt.items() if v == top_n])
236
+ if len(top_levels) == 1:
237
+ return top_levels[0]
238
+
239
+ # 平票时,用多次预测的均值 score 判定等级。
240
+ if scores:
241
+ mean_score = sum(scores) / len(scores)
242
+ return score_to_submission_level(mean_score, low_threshold=low_threshold, high_threshold=high_threshold)
243
+
244
+ if fallback_level in {"A", "B", "C"}:
245
+ return fallback_level
246
+ return "B"
247
+
248
+
249
+ def parse_weights(raw_weights: Optional[List[float]], n_models: int) -> List[float]:
250
+ # 默认等权;若传入权重则要求和预测文件数一致。
251
+ if raw_weights is None:
252
+ return [1.0] * n_models
253
+ if len(raw_weights) != n_models:
254
+ raise ValueError(
255
+ f"--weights length ({len(raw_weights)}) must equal number of prediction files ({n_models})."
256
+ )
257
+ for w in raw_weights:
258
+ if w < 0:
259
+ raise ValueError("weights must be non-negative.")
260
+ # 全零没有意义,回退等权。
261
+ if sum(raw_weights) == 0:
262
+ return [1.0] * n_models
263
+ return raw_weights
264
+
265
+
266
+ def load_thresholds(
267
+ thresholds_json: str,
268
+ ) -> Tuple[Dict[str, Dict[str, float]], Dict[str, float]]:
269
+ """
270
+ Accepts JSON in either format:
271
+ 1) {"criteria": {"Color Harmony": {"low": 4.9, "high": 7.1}}, "default": {"low":5,"high":7}}
272
+ 2) {"Color Harmony": {"low": 4.9, "high": 7.1}, ...}
273
+ """
274
+ default = {"low": 5.0, "high": 7.0}
275
+ per_criteria: Dict[str, Dict[str, float]] = {}
276
+
277
+ if not thresholds_json:
278
+ return per_criteria, default
279
+ if not os.path.exists(thresholds_json):
280
+ print(f"[WARN] thresholds file not found: {thresholds_json}, fallback to default 5/7")
281
+ return per_criteria, default
282
+
283
+ obj = load_json(thresholds_json)
284
+ if not isinstance(obj, dict):
285
+ print(f"[WARN] invalid thresholds json format: {thresholds_json}, fallback to default 5/7")
286
+ return per_criteria, default
287
+
288
+ if "default" in obj and isinstance(obj.get("default"), dict):
289
+ d = obj["default"]
290
+ low = d.get("low", 5.0)
291
+ high = d.get("high", 7.0)
292
+ try:
293
+ low_f = float(low)
294
+ high_f = float(high)
295
+ if low_f < high_f:
296
+ default = {"low": low_f, "high": high_f}
297
+ except Exception:
298
+ pass
299
+
300
+ src = obj.get("criteria") if isinstance(obj.get("criteria"), dict) else obj
301
+ if isinstance(src, dict):
302
+ for k, v in src.items():
303
+ if not isinstance(v, dict):
304
+ continue
305
+ if "low" not in v or "high" not in v:
306
+ continue
307
+ try:
308
+ low = float(v["low"])
309
+ high = float(v["high"])
310
+ except Exception:
311
+ continue
312
+ if low < high:
313
+ per_criteria[str(k)] = {"low": low, "high": high}
314
+
315
+ return per_criteria, default
316
+
317
+
318
+ def resolve_best_index(best_index: int, weights: List[float]) -> int:
319
+ # best_index=-1 表示自动选择权重最高的模型作为平票时的优先模型。
320
+ if best_index >= 0:
321
+ if best_index >= len(weights):
322
+ raise ValueError(f"--best_index out of range: {best_index}, num_models={len(weights)}")
323
+ return best_index
324
+ return max(range(len(weights)), key=lambda i: weights[i])
325
+
326
+
327
+ def extract_criteria_voting(
328
+ template_item: Dict[str, Any],
329
+ pred_objs: List[Optional[Dict[str, Any]]],
330
+ pred_texts: List[str],
331
+ per_criteria_thresholds: Dict[str, Dict[str, float]],
332
+ default_thresholds: Dict[str, float],
333
+ ) -> Dict[str, Dict[str, str]]:
334
+ out: Dict[str, Dict[str, str]] = {}
335
+
336
+ for crit_key, crit_val in template_item.get("criteria", {}).items():
337
+ thresholds = per_criteria_thresholds.get(crit_key, default_thresholds)
338
+ low = float(thresholds.get("low", 5.0))
339
+ high = float(thresholds.get("high", 7.0))
340
+ if not (low < high):
341
+ low, high = 5.0, 7.0
342
+
343
+ level_votes: List[str] = []
344
+ score_votes: List[float] = []
345
+ for obj, text in zip(pred_objs, pred_texts):
346
+ level, score = extract_one_criteria_level_and_score(crit_key, obj, text)
347
+ if level is not None:
348
+ level_votes.append(level)
349
+ if score is not None:
350
+ score_votes.append(score)
351
+
352
+ prev = str(crit_val.get("level", "")).strip() if isinstance(crit_val, dict) else ""
353
+ final_level = choose_majority_level(
354
+ level_votes,
355
+ score_votes,
356
+ prev,
357
+ low_threshold=low,
358
+ high_threshold=high,
359
+ )
360
+ out[crit_key] = {"level": final_level}
361
+
362
+ return out
363
+
364
+
365
+ def pick_default_predictions_path() -> str:
366
+ candidates = [
367
+ "outputs/predictions/predict_ckpt2660/generated_predictions.jsonl",
368
+ "outputs/predictions/generated_predictions.jsonl",
369
+ ]
370
+ # 自动兜底:在预测目录里找最近一次 generated_predictions.jsonl。
371
+ dynamic = sorted(
372
+ glob("outputs/predictions/**/generated_predictions.jsonl", recursive=True),
373
+ key=lambda x: os.path.getmtime(x),
374
+ reverse=True,
375
+ )
376
+ candidates = dynamic + candidates
377
+
378
+ for p in candidates:
379
+ if os.path.exists(p):
380
+ return p
381
+ return DEFAULT_PRED_PATH
382
+
383
+
384
+ def choose_weighted_answer(
385
+ votes_by_model: List[Optional[str]],
386
+ weights: List[float],
387
+ best_index: int,
388
+ tie_break_answer: Optional[str],
389
+ ) -> str:
390
+ """
391
+ 更合理的答案融合策略:
392
+ 1) 加权投票(按各变体可靠性权重)
393
+ 2) 若平票且提供 tie-break 结果,则优先用 tie-break
394
+ 3) 若仍平票,采用最佳变体(best_index)在平票选项中的答案
395
+ 4) 最后才做稳定兜底(字母序)
396
+ """
397
+ label_scores = {"A": 0.0, "B": 0.0, "C": 0.0, "D": 0.0}
398
+ for i, ans in enumerate(votes_by_model):
399
+ if ans in label_scores:
400
+ label_scores[ans] += weights[i]
401
+
402
+ max_score = max(label_scores.values())
403
+ if max_score <= 0:
404
+ return "A"
405
+
406
+ tied = sorted([k for k, v in label_scores.items() if v == max_score])
407
+ if len(tied) == 1:
408
+ return tied[0]
409
+
410
+ if tie_break_answer in tied:
411
+ return tie_break_answer # 用专门 tie-break 结果判平票
412
+
413
+ best_vote = votes_by_model[best_index]
414
+ if best_vote in tied:
415
+ return str(best_vote)
416
+
417
+ return tied[0]
418
+
419
+
420
+ def main() -> None:
421
+ parser = argparse.ArgumentParser()
422
+ parser.add_argument("--template_json", type=str, default=DEFAULT_TEMPLATE_PATH)
423
+ parser.add_argument(
424
+ "--predictions_jsonl",
425
+ type=str,
426
+ nargs="+",
427
+ default=None,
428
+ help="One or more generated_predictions.jsonl paths for voting.",
429
+ )
430
+ parser.add_argument(
431
+ "--weights",
432
+ type=float,
433
+ nargs="+",
434
+ default=None,
435
+ help="Optional weights for prediction files (same length as --predictions_jsonl).",
436
+ )
437
+ parser.add_argument(
438
+ "--best_index",
439
+ type=int,
440
+ default=-1,
441
+ help="Best model index for tie fallback. -1 means auto argmax(weights).",
442
+ )
443
+ parser.add_argument(
444
+ "--tie_break_jsonl",
445
+ type=str,
446
+ default="",
447
+ help="Optional tie-break predictions file used only when weighted vote ties.",
448
+ )
449
+ parser.add_argument(
450
+ "--thresholds_json",
451
+ type=str,
452
+ default="",
453
+ help="Optional per-criterion thresholds json for score->A/B/C mapping.",
454
+ )
455
+ parser.add_argument(
456
+ "--total_score_fusion",
457
+ type=str,
458
+ default="mean",
459
+ choices=["mean", "trim_mean"],
460
+ help="Fusion mode for total_score across multi-prompt predictions.",
461
+ )
462
+ parser.add_argument("--output_json", type=str, default=DEFAULT_OUTPUT_PATH)
463
+ args = parser.parse_args()
464
+
465
+ pred_paths = args.predictions_jsonl if args.predictions_jsonl else [pick_default_predictions_path()]
466
+ pred_paths = [p for p in pred_paths if str(p).strip()]
467
+ if not pred_paths:
468
+ raise ValueError("No predictions_jsonl provided or discovered.")
469
+
470
+ template_data = load_json(args.template_json)
471
+ pred_sets = [load_jsonl(p) for p in pred_paths]
472
+ weights = parse_weights(args.weights, len(pred_sets))
473
+ best_index = resolve_best_index(args.best_index, weights)
474
+ per_criteria_thresholds, default_thresholds = load_thresholds(args.thresholds_json)
475
+ tie_break_rows: List[Dict[str, Any]] = []
476
+ if args.tie_break_jsonl:
477
+ tie_break_rows = load_jsonl(args.tie_break_jsonl)
478
+
479
+ if not isinstance(template_data, list):
480
+ raise ValueError("template_json must be a list.")
481
+
482
+ print(f"[INFO] template items: {len(template_data)}")
483
+ for p, rows in zip(pred_paths, pred_sets):
484
+ print(f"[INFO] prediction rows: {len(rows)} ({p})")
485
+ print(f"[INFO] answer weights: {weights}")
486
+ print(f"[INFO] answer best_index: {best_index}")
487
+ print(f"[INFO] criteria thresholds default: low={default_thresholds['low']}, high={default_thresholds['high']}")
488
+ if per_criteria_thresholds:
489
+ print(f"[INFO] criteria thresholds loaded: {len(per_criteria_thresholds)}")
490
+ if args.tie_break_jsonl:
491
+ print(f"[INFO] tie_break rows: {len(tie_break_rows)} ({args.tie_break_jsonl})")
492
+
493
+ final_submission: List[Dict[str, Any]] = []
494
+ parsed_ok = 0
495
+ filled = 0
496
+
497
+ for i, item in enumerate(template_data):
498
+ new_item = copy.deepcopy(item)
499
+ # 收集每次预测在第 i 条样本上的结果。
500
+ row_texts: List[str] = []
501
+ row_objs: List[Optional[Dict[str, Any]]] = []
502
+ answer_votes_by_model: List[Optional[str]] = []
503
+ total_votes: List[int] = []
504
+
505
+ for rows in pred_sets:
506
+ if i >= len(rows):
507
+ answer_votes_by_model.append(None)
508
+ continue
509
+ pred_text = normalize_prediction_text(rows[i])
510
+ pred_obj = try_parse_predict_json(pred_text)
511
+ if pred_obj is not None:
512
+ parsed_ok += 1
513
+ row_texts.append(pred_text)
514
+ row_objs.append(pred_obj)
515
+
516
+ ans = extract_answer(pred_obj, pred_text)
517
+ answer_votes_by_model.append(ans)
518
+
519
+ ts = extract_total_score(pred_obj, pred_text)
520
+ if ts is not None:
521
+ total_votes.append(ts)
522
+
523
+ if not row_texts:
524
+ final_submission.append(new_item)
525
+ continue
526
+
527
+ new_item["criteria"] = extract_criteria_voting(
528
+ new_item,
529
+ row_objs,
530
+ row_texts,
531
+ per_criteria_thresholds=per_criteria_thresholds,
532
+ default_thresholds=default_thresholds,
533
+ )
534
+
535
+ # total_score 用多次预测均值,减少单次抖动。
536
+ merged_total = merge_total_scores(total_votes, args.total_score_fusion)
537
+ if merged_total is not None:
538
+ new_item["total_score"] = max(0, min(100, merged_total))
539
+
540
+ tie_break_answer: Optional[str] = None
541
+ if i < len(tie_break_rows):
542
+ tb_text = normalize_prediction_text(tie_break_rows[i])
543
+ tb_obj = try_parse_predict_json(tb_text)
544
+ tie_break_answer = extract_answer(tb_obj, tb_text)
545
+
546
+ new_item["answer"] = choose_weighted_answer(
547
+ votes_by_model=answer_votes_by_model,
548
+ weights=weights,
549
+ best_index=best_index,
550
+ tie_break_answer=tie_break_answer,
551
+ )
552
+
553
+ final_submission.append(new_item)
554
+ filled += 1
555
+
556
+ os.makedirs(os.path.dirname(args.output_json), exist_ok=True)
557
+ with open(args.output_json, "w", encoding="utf-8") as f:
558
+ json.dump(final_submission, f, ensure_ascii=False, indent=2)
559
+
560
+ parsed_total = filled * len(pred_paths)
561
+ print(f"[INFO] parsed predict json ok: {parsed_ok}/{parsed_total}")
562
+ print(f"[INFO] saved submission: {args.output_json}")
563
+
564
+
565
+ if __name__ == "__main__":
566
+ main()
portrait_composition_workflow_vector.svg ADDED
prepare_dataset.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import os
8
+ import random
9
+ from collections import Counter, defaultdict
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, List, Tuple
12
+
13
+
14
+ CRITERIA = [
15
+ "Color Harmony",
16
+ "Visual Style Consistency",
17
+ "Sharpness",
18
+ "Light and Shadow Modeling",
19
+ "Creativity and Originality",
20
+ "Exposure Control",
21
+ "Application of Classical Composition Principles",
22
+ "Depth of Field and Layering",
23
+ "Visual Center Stability",
24
+ "Visual Flow Guidance",
25
+ "Structural Support Stability",
26
+ "Appropriateness of Negative Space",
27
+ "Subject Integrity",
28
+ ]
29
+
30
+
31
+ PROMPT_SCORE = (
32
+ "You are an aesthetics expert. Evaluate the image on the following 13 criteria:\n"
33
+ "Color Harmony, Visual Style Consistency, Sharpness, Light and Shadow Modeling, "
34
+ "Creativity and Originality, Exposure Control, Application of Classical Composition Principles, "
35
+ "Depth of Field and Layering, Visual Center Stability, Visual Flow Guidance, "
36
+ "Structural Support Stability, Appropriateness of Negative Space, Subject Integrity.\n\n"
37
+ "For each criterion, output a numeric score in [0.0, 10.0].\n"
38
+ "Then output total_score as an integer in [0, 100].\n"
39
+ "Return STRICT JSON only.\n"
40
+ "JSON format:\n"
41
+ "{\n"
42
+ " \"criteria\": {\n"
43
+ " \"Color Harmony\": {\"score\": 0.0}\n"
44
+ " },\n"
45
+ " \"total_score\": 0\n"
46
+ "}\n\n"
47
+ "<image>"
48
+ )
49
+
50
+
51
+ PROMPT_MULTITASK = (
52
+ "You are an aesthetics expert. Evaluate the image on the following 13 criteria:\n"
53
+ "Color Harmony, Visual Style Consistency, Sharpness, Light and Shadow Modeling, "
54
+ "Creativity and Originality, Exposure Control, Application of Classical Composition Principles, "
55
+ "Depth of Field and Layering, Visual Center Stability, Visual Flow Guidance, "
56
+ "Structural Support Stability, Appropriateness of Negative Space, Subject Integrity.\n\n"
57
+ "For each criterion, output:\n"
58
+ "1) score in [0.0, 10.0]\n"
59
+ "2) level in {A,B,C} where A:<5, B:[5,7), C:>=7\n"
60
+ "Then output total_score as an integer in [0, 100].\n"
61
+ "Return STRICT JSON only.\n"
62
+ "JSON format:\n"
63
+ "{\n"
64
+ " \"criteria\": {\n"
65
+ " \"Color Harmony\": {\"score\": 0.0, \"level\": \"A|B|C\"}\n"
66
+ " },\n"
67
+ " \"total_score\": 0\n"
68
+ "}\n\n"
69
+ "<image>"
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class Sample:
75
+ image_path: str
76
+ scores: Dict[str, float]
77
+ levels: Dict[str, str]
78
+ total_score: int
79
+ boundary_count: int
80
+
81
+
82
+ def clamp_score(x: float) -> float:
83
+ return max(0.0, min(10.0, x))
84
+
85
+
86
+ def clamp_int(x: int, lo: int, hi: int) -> int:
87
+ return max(lo, min(hi, x))
88
+
89
+
90
+ def score_to_level(score: float) -> str:
91
+ if score < 5.0:
92
+ return "A"
93
+ if score < 7.0:
94
+ return "B"
95
+ return "C"
96
+
97
+
98
+ def safe_float(x: Any, default: float = 0.0) -> float:
99
+ try:
100
+ return float(x)
101
+ except Exception:
102
+ return default
103
+
104
+
105
+ def safe_int(x: Any, default: int = 0) -> int:
106
+ try:
107
+ return int(round(float(x)))
108
+ except Exception:
109
+ return default
110
+
111
+
112
+ def parse_assistant_payload(payload: str) -> Dict[str, Any]:
113
+ obj = json.loads(payload)
114
+ if not isinstance(obj, dict):
115
+ raise ValueError("assistant payload is not dict")
116
+ return obj
117
+
118
+
119
+ def normalize_record(rec: Dict[str, Any], boundary_margin: float) -> Sample:
120
+ assistant = parse_assistant_payload(rec["messages"][1]["content"])
121
+ criteria = assistant.get("criteria", {})
122
+
123
+ scores: Dict[str, float] = {}
124
+ levels: Dict[str, str] = {}
125
+ boundary_count = 0
126
+
127
+ for c in CRITERIA:
128
+ v = (criteria.get(c, {}) or {}).get("score", 0.0)
129
+ s = round(clamp_score(safe_float(v, 0.0)), 1)
130
+ lv = score_to_level(s)
131
+ scores[c] = s
132
+ levels[c] = lv
133
+ if abs(s - 5.0) <= boundary_margin or abs(s - 7.0) <= boundary_margin:
134
+ boundary_count += 1
135
+
136
+ t = assistant.get("total_score", 0)
137
+ total_score = clamp_int(safe_int(t, 0), 0, 100)
138
+
139
+ img = rec.get("images", [""])
140
+ image_path = str(img[0]) if isinstance(img, list) and img else ""
141
+
142
+ return Sample(
143
+ image_path=image_path,
144
+ scores=scores,
145
+ levels=levels,
146
+ total_score=total_score,
147
+ boundary_count=boundary_count,
148
+ )
149
+
150
+
151
+ def build_score_record(s: Sample) -> Dict[str, Any]:
152
+ assistant = {
153
+ "criteria": {k: {"score": round(v, 1)} for k, v in s.scores.items()},
154
+ "total_score": int(s.total_score),
155
+ }
156
+ return {
157
+ "messages": [
158
+ {"role": "user", "content": PROMPT_SCORE},
159
+ {"role": "assistant", "content": json.dumps(assistant, ensure_ascii=False, indent=2)},
160
+ ],
161
+ "images": [s.image_path],
162
+ }
163
+
164
+
165
+ def build_multitask_record(s: Sample) -> Dict[str, Any]:
166
+ assistant = {
167
+ "criteria": {
168
+ k: {"score": round(s.scores[k], 1), "level": s.levels[k]} for k in CRITERIA
169
+ },
170
+ "total_score": int(s.total_score),
171
+ }
172
+ return {
173
+ "messages": [
174
+ {"role": "user", "content": PROMPT_MULTITASK},
175
+ {"role": "assistant", "content": json.dumps(assistant, ensure_ascii=False, indent=2)},
176
+ ],
177
+ "images": [s.image_path],
178
+ }
179
+
180
+
181
+ def compute_level_counts(samples: List[Sample]) -> Dict[str, Counter]:
182
+ counts: Dict[str, Counter] = {c: Counter() for c in CRITERIA}
183
+ for s in samples:
184
+ for c in CRITERIA:
185
+ counts[c][s.levels[c]] += 1
186
+ return counts
187
+
188
+
189
+ def compute_sample_dup(
190
+ s: Sample,
191
+ level_counts: Dict[str, Counter],
192
+ max_class_weight: float,
193
+ boundary_bonus: float,
194
+ max_dup: int,
195
+ ) -> int:
196
+ weights: List[float] = []
197
+ for c in CRITERIA:
198
+ cnt = level_counts[c]
199
+ max_freq = max(cnt.values()) if cnt else 1
200
+ cur = cnt.get(s.levels[c], 1)
201
+ w = math.sqrt(float(max_freq) / float(max(1, cur)))
202
+ w = min(max_class_weight, max(1.0, w))
203
+ weights.append(w)
204
+
205
+ avg_w = sum(weights) / len(weights)
206
+ raw_dup = avg_w + boundary_bonus * float(s.boundary_count)
207
+ dup = int(round(raw_dup))
208
+ return clamp_int(dup, 1, max_dup)
209
+
210
+
211
+ def save_json(path: str, obj: Any) -> None:
212
+ os.makedirs(os.path.dirname(path), exist_ok=True)
213
+ with open(path, "w", encoding="utf-8") as f:
214
+ json.dump(obj, f, ensure_ascii=False, indent=2)
215
+
216
+
217
+ def build_stats(
218
+ train_samples: List[Sample],
219
+ val_samples: List[Sample],
220
+ level_counts: Dict[str, Counter],
221
+ dup_counts: Counter,
222
+ balanced_size: int,
223
+ ) -> Dict[str, Any]:
224
+ agg = Counter()
225
+ for c in CRITERIA:
226
+ agg.update(level_counts[c])
227
+
228
+ total_lv = sum(agg.values())
229
+ agg_ratio = {k: round(v / total_lv, 6) for k, v in agg.items()} if total_lv else {}
230
+
231
+ per_criterion = {}
232
+ for c in CRITERIA:
233
+ cnt = level_counts[c]
234
+ n = sum(cnt.values())
235
+ per_criterion[c] = {
236
+ "counts": dict(cnt),
237
+ "ratio": {k: round(cnt[k] / n, 6) if n else 0.0 for k in ["A", "B", "C"]},
238
+ }
239
+
240
+ train_total = [s.total_score for s in train_samples]
241
+ val_total = [s.total_score for s in val_samples]
242
+
243
+ return {
244
+ "train_count": len(train_samples),
245
+ "val_count": len(val_samples),
246
+ "balanced_train_count": balanced_size,
247
+ "level_distribution_aggregate": dict(agg),
248
+ "level_distribution_aggregate_ratio": agg_ratio,
249
+ "level_distribution_per_criterion": per_criterion,
250
+ "duplication_histogram": {str(k): v for k, v in sorted(dup_counts.items())},
251
+ "boundary_stats": {
252
+ "train_mean_boundary_count": round(sum(s.boundary_count for s in train_samples) / max(1, len(train_samples)), 4),
253
+ "train_max_boundary_count": max((s.boundary_count for s in train_samples), default=0),
254
+ },
255
+ "total_score": {
256
+ "train_mean": round(sum(train_total) / max(1, len(train_total)), 4),
257
+ "val_mean": round(sum(val_total) / max(1, len(val_total)), 4),
258
+ "train_min": min(train_total) if train_total else None,
259
+ "train_max": max(train_total) if train_total else None,
260
+ "val_min": min(val_total) if val_total else None,
261
+ "val_max": max(val_total) if val_total else None,
262
+ },
263
+ }
264
+
265
+
266
+ def main() -> None:
267
+ parser = argparse.ArgumentParser()
268
+ parser.add_argument(
269
+ "--train_json",
270
+ type=str,
271
+ default="./codabench_portrait_train.json",
272
+ )
273
+ parser.add_argument(
274
+ "--val_json",
275
+ type=str,
276
+ default="./codabench_portrait_val.json",
277
+ )
278
+ parser.add_argument("--output_dir", type=str, default=".")
279
+ parser.add_argument("--seed", type=int, default=42)
280
+ parser.add_argument("--boundary_margin", type=float, default=0.4)
281
+ parser.add_argument("--boundary_bonus", type=float, default=0.3)
282
+ parser.add_argument("--max_class_weight", type=float, default=4.0)
283
+ parser.add_argument("--max_dup", type=int, default=5)
284
+ parser.add_argument(
285
+ "--stats_json",
286
+ type=str,
287
+ default="logs/prepare_dataset_v2_stats.json",
288
+ )
289
+ args = parser.parse_args()
290
+
291
+ random.seed(args.seed)
292
+
293
+ with open(args.train_json, "r", encoding="utf-8") as f:
294
+ train_raw = json.load(f)
295
+ with open(args.val_json, "r", encoding="utf-8") as f:
296
+ val_raw = json.load(f)
297
+
298
+ train_samples = [normalize_record(x, args.boundary_margin) for x in train_raw]
299
+ val_samples = [normalize_record(x, args.boundary_margin) for x in val_raw]
300
+
301
+ level_counts = compute_level_counts(train_samples)
302
+
303
+ train_score = [build_score_record(s) for s in train_samples]
304
+ train_multitask = [build_multitask_record(s) for s in train_samples]
305
+ val_score = [build_score_record(s) for s in val_samples]
306
+ val_multitask = [build_multitask_record(s) for s in val_samples]
307
+
308
+ train_balanced: List[Dict[str, Any]] = []
309
+ dup_hist = Counter()
310
+ for s in train_samples:
311
+ dup = compute_sample_dup(
312
+ s=s,
313
+ level_counts=level_counts,
314
+ max_class_weight=args.max_class_weight,
315
+ boundary_bonus=args.boundary_bonus,
316
+ max_dup=args.max_dup,
317
+ )
318
+ dup_hist[dup] += 1
319
+ rec = build_multitask_record(s)
320
+ for _ in range(dup):
321
+ train_balanced.append(rec)
322
+
323
+ random.shuffle(train_balanced)
324
+
325
+ os.makedirs(args.output_dir, exist_ok=True)
326
+
327
+ f_train_score = os.path.join(args.output_dir, "codabench_portrait_train_score_v2.json")
328
+ f_train_multitask = os.path.join(args.output_dir, "codabench_portrait_train_multitask_v2.json")
329
+ f_train_balanced = os.path.join(args.output_dir, "codabench_portrait_train_balanced_v2.json")
330
+ f_val_score = os.path.join(args.output_dir, "codabench_portrait_val_score_v2.json")
331
+ f_val_multitask = os.path.join(args.output_dir, "codabench_portrait_val_multitask_v2.json")
332
+
333
+ save_json(f_train_score, train_score)
334
+ save_json(f_train_multitask, train_multitask)
335
+ save_json(f_train_balanced, train_balanced)
336
+ save_json(f_val_score, val_score)
337
+ save_json(f_val_multitask, val_multitask)
338
+
339
+ stats = build_stats(
340
+ train_samples=train_samples,
341
+ val_samples=val_samples,
342
+ level_counts=level_counts,
343
+ dup_counts=dup_hist,
344
+ balanced_size=len(train_balanced),
345
+ )
346
+ save_json(args.stats_json, stats)
347
+
348
+ print("=" * 60)
349
+ print("saved:", f_train_score)
350
+ print("saved:", f_train_multitask)
351
+ print("saved:", f_train_balanced)
352
+ print("saved:", f_val_score)
353
+ print("saved:", f_val_multitask)
354
+ print("stats:", args.stats_json)
355
+ print("train_count:", len(train_samples), "balanced_count:", len(train_balanced), "val_count:", len(val_samples))
356
+ print("dup_hist:", dict(sorted(dup_hist.items())))
357
+ print("=" * 60)
358
+
359
+
360
+ if __name__ == "__main__":
361
+ main()
prepare_test_prompt_v2.py ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import os
4
+ from typing import Dict, List
5
+
6
+ CRITERIA = [
7
+ "Color Harmony",
8
+ "Visual Style Consistency",
9
+ "Sharpness",
10
+ "Light and Shadow Modeling",
11
+ "Creativity and Originality",
12
+ "Exposure Control",
13
+ "Application of Classical Composition Principles",
14
+ "Depth of Field and Layering",
15
+ "Visual Center Stability",
16
+ "Visual Flow Guidance",
17
+ "Structural Support Stability",
18
+ "Appropriateness of Negative Space",
19
+ "Subject Integrity",
20
+ ]
21
+
22
+ # 三组不同措辞的 prompt,用于后续多次推理投票。
23
+ # 输出 schema 完全一致,避免后处理复杂化。
24
+ PROMPT_VARIANTS = {
25
+ "v1": """You are a portrait-aesthetics judge focused on robust cross-image ranking.
26
+
27
+ Task:
28
+ - Score all 13 criteria with floats in [0.0, 10.0].
29
+ - Output total_score as an integer in [0, 100].
30
+ - Choose answer from A/B/C/D.
31
+
32
+ Scoring policy for total_score:
33
+ - Use holistic visual quality, not a plain arithmetic average of 13 scores.
34
+ - Keep strong separation when quality difference is obvious.
35
+ - Penalize major defects decisively (blur, broken anatomy, unstable structure, severe exposure failure).
36
+ - Reward clearly excellent lighting/composition/subject quality.
37
+
38
+ Typical anchors (not strict formula):
39
+ - severe failure: 0-30
40
+ - poor: 31-45
41
+ - fair: 46-60
42
+ - good: 61-75
43
+ - very good: 76-88
44
+ - excellent: 89-100
45
+
46
+ Hard output constraints:
47
+ - STRICT JSON only.
48
+ - No level/reason/x.
49
+ - No extra keys.
50
+ - One decimal place for criterion scores when possible.
51
+
52
+ Return exactly:
53
+ {
54
+ "criteria": {
55
+ "Color Harmony": {"score": 0.0},
56
+ "Visual Style Consistency": {"score": 0.0},
57
+ "Sharpness": {"score": 0.0},
58
+ "Light and Shadow Modeling": {"score": 0.0},
59
+ "Creativity and Originality": {"score": 0.0},
60
+ "Exposure Control": {"score": 0.0},
61
+ "Application of Classical Composition Principles": {"score": 0.0},
62
+ "Depth of Field and Layering": {"score": 0.0},
63
+ "Visual Center Stability": {"score": 0.0},
64
+ "Visual Flow Guidance": {"score": 0.0},
65
+ "Structural Support Stability": {"score": 0.0},
66
+ "Appropriateness of Negative Space": {"score": 0.0},
67
+ "Subject Integrity": {"score": 0.0}
68
+ },
69
+ "total_score": 0,
70
+ "answer": "A|B|C|D"
71
+ }
72
+
73
+ Question: __QUESTION__
74
+ Options:
75
+ A: __OPT_A__
76
+ B: __OPT_B__
77
+ C: __OPT_C__
78
+ D: __OPT_D__
79
+
80
+ <image>""",
81
+ "v2": """Act as a strict technical-quality portrait judge.
82
+
83
+ Priority:
84
+ 1) Technical correctness first (sharpness, exposure, structure, subject integrity).
85
+ 2) Then composition and visual flow.
86
+ 3) Then style/creativity refinement.
87
+
88
+ Rules:
89
+ - If critical technical defects are obvious, lower affected criteria strongly and reduce total_score accordingly.
90
+ - Do not overuse middle scores.
91
+ - total_score must reflect defect severity and overall impression, not simple averaging.
92
+ - answer must be one of A/B/C/D using visible evidence.
93
+
94
+ Output constraints:
95
+ - criteria scores: [0.0, 10.0], one decimal when possible
96
+ - total_score: integer [0, 100]
97
+ - STRICT JSON only, no extra text/keys
98
+ - no level/reason/x
99
+
100
+ Return JSON schema:
101
+ {
102
+ "criteria": {
103
+ "Color Harmony": {"score": 0.0},
104
+ "Visual Style Consistency": {"score": 0.0},
105
+ "Sharpness": {"score": 0.0},
106
+ "Light and Shadow Modeling": {"score": 0.0},
107
+ "Creativity and Originality": {"score": 0.0},
108
+ "Exposure Control": {"score": 0.0},
109
+ "Application of Classical Composition Principles": {"score": 0.0},
110
+ "Depth of Field and Layering": {"score": 0.0},
111
+ "Visual Center Stability": {"score": 0.0},
112
+ "Visual Flow Guidance": {"score": 0.0},
113
+ "Structural Support Stability": {"score": 0.0},
114
+ "Appropriateness of Negative Space": {"score": 0.0},
115
+ "Subject Integrity": {"score": 0.0}
116
+ },
117
+ "total_score": 0,
118
+ "answer": "A|B|C|D"
119
+ }
120
+
121
+ Question: __QUESTION__
122
+ Options:
123
+ A: __OPT_A__
124
+ B: __OPT_B__
125
+ C: __OPT_C__
126
+ D: __OPT_D__
127
+
128
+ <image>""",
129
+ "v3": """You are a composition-and-lighting focused portrait evaluator for leaderboard ranking.
130
+
131
+ Focus:
132
+ - Composition principles, visual center stability, flow guidance, light-shadow modeling, depth layering.
133
+ - Keep technical criteria accurate as well.
134
+ - Use discriminative scoring; avoid central-score collapse.
135
+
136
+ total_score guidance:
137
+ - Reflect global aesthetics and scene coherence.
138
+ - Do NOT compute by fixed linear formula from criteria.
139
+ - If composition/light is outstanding and major defects are absent, allow high total_score.
140
+ - If visual organization is weak or unstable, lower total_score decisively.
141
+
142
+ Output:
143
+ - Strict JSON only, exact keys.
144
+ - criteria scores in [0.0,10.0], one decimal when possible.
145
+ - total_score integer [0,100].
146
+ - answer exactly A/B/C/D.
147
+ - no level/reason/x, no extra keys.
148
+
149
+ JSON schema:
150
+ {
151
+ "criteria": {
152
+ "Color Harmony": {"score": 0.0},
153
+ "Visual Style Consistency": {"score": 0.0},
154
+ "Sharpness": {"score": 0.0},
155
+ "Light and Shadow Modeling": {"score": 0.0},
156
+ "Creativity and Originality": {"score": 0.0},
157
+ "Exposure Control": {"score": 0.0},
158
+ "Application of Classical Composition Principles": {"score": 0.0},
159
+ "Depth of Field and Layering": {"score": 0.0},
160
+ "Visual Center Stability": {"score": 0.0},
161
+ "Visual Flow Guidance": {"score": 0.0},
162
+ "Structural Support Stability": {"score": 0.0},
163
+ "Appropriateness of Negative Space": {"score": 0.0},
164
+ "Subject Integrity": {"score": 0.0}
165
+ },
166
+ "total_score": 0,
167
+ "answer": "A|B|C|D"
168
+ }
169
+
170
+ Question: __QUESTION__
171
+ Options:
172
+ A: __OPT_A__
173
+ B: __OPT_B__
174
+ C: __OPT_C__
175
+ D: __OPT_D__
176
+
177
+ <image>""",
178
+ "v4": """You are a conservative-but-discriminative portrait quality judge.
179
+
180
+ Policy:
181
+ - Be strict on obvious flaws.
182
+ - Do not suppress clearly high-quality images.
183
+ - Keep ranking-sensitive separation: when two quality levels are visibly different, total_score should differ clearly.
184
+
185
+ Internal check before final JSON:
186
+ - Are penalized criteria exactly those with visible defects?
187
+ - Is total_score coherent with overall impression?
188
+ - Is answer choice visually supported?
189
+
190
+ Hard constraints:
191
+ - criteria score float in [0.0,10.0]
192
+ - total_score integer [0,100]
193
+ - answer in A/B/C/D
194
+ - strict JSON only
195
+ - no level/reason/x
196
+ - no extra keys
197
+
198
+ Schema:
199
+ {
200
+ "criteria": {
201
+ "Color Harmony": {"score": 0.0},
202
+ "Visual Style Consistency": {"score": 0.0},
203
+ "Sharpness": {"score": 0.0},
204
+ "Light and Shadow Modeling": {"score": 0.0},
205
+ "Creativity and Originality": {"score": 0.0},
206
+ "Exposure Control": {"score": 0.0},
207
+ "Application of Classical Composition Principles": {"score": 0.0},
208
+ "Depth of Field and Layering": {"score": 0.0},
209
+ "Visual Center Stability": {"score": 0.0},
210
+ "Visual Flow Guidance": {"score": 0.0},
211
+ "Structural Support Stability": {"score": 0.0},
212
+ "Appropriateness of Negative Space": {"score": 0.0},
213
+ "Subject Integrity": {"score": 0.0}
214
+ },
215
+ "total_score": 0,
216
+ "answer": "A|B|C|D"
217
+ }
218
+
219
+ Question: __QUESTION__
220
+ Options:
221
+ A: __OPT_A__
222
+ B: __OPT_B__
223
+ C: __OPT_C__
224
+ D: __OPT_D__
225
+
226
+ <image>""",
227
+ "v5": """You are an expert portrait evaluator optimized for SRCC/PLCC robustness.
228
+
229
+ Scoring intent:
230
+ - Produce stable, monotonic quality ranking across images.
231
+ - Use full score range when evidence is strong.
232
+ - Avoid producing similar total_score for clearly different quality.
233
+ - Keep criteria pattern and total_score logically aligned, but not by fixed linear averaging.
234
+
235
+ Practical guide:
236
+ - Multiple severe issues -> usually <=45.
237
+ - Noticeable technical/compositional weakness -> usually 46-60.
238
+ - Solid quality with minor issues -> usually 61-75.
239
+ - Strong quality with no major defects -> usually 76-88.
240
+ - Exceptional overall result -> usually 89-100.
241
+
242
+ Output rules:
243
+ - STRICT JSON only.
244
+ - No markdown, no explanation, no extra keys.
245
+ - No level/reason/x.
246
+ - criteria score float [0.0,10.0], one decimal when possible.
247
+ - total_score integer [0,100].
248
+ - answer exactly A/B/C/D.
249
+
250
+ Schema:
251
+ {
252
+ "criteria": {
253
+ "Color Harmony": {"score": 0.0},
254
+ "Visual Style Consistency": {"score": 0.0},
255
+ "Sharpness": {"score": 0.0},
256
+ "Light and Shadow Modeling": {"score": 0.0},
257
+ "Creativity and Originality": {"score": 0.0},
258
+ "Exposure Control": {"score": 0.0},
259
+ "Application of Classical Composition Principles": {"score": 0.0},
260
+ "Depth of Field and Layering": {"score": 0.0},
261
+ "Visual Center Stability": {"score": 0.0},
262
+ "Visual Flow Guidance": {"score": 0.0},
263
+ "Structural Support Stability": {"score": 0.0},
264
+ "Appropriateness of Negative Space": {"score": 0.0},
265
+ "Subject Integrity": {"score": 0.0}
266
+ },
267
+ "total_score": 0,
268
+ "answer": "A|B|C|D"
269
+ }
270
+
271
+ Question: __QUESTION__
272
+ Options:
273
+ A: __OPT_A__
274
+ B: __OPT_B__
275
+ C: __OPT_C__
276
+ D: __OPT_D__
277
+
278
+ <image>""",
279
+ "v6": """You are a defect-sensitive portrait aesthetics evaluator.
280
+ Prioritize technical reliability before style bonus.
281
+
282
+ Rules:
283
+ - Score 13 criteria in [0.0, 10.0], one decimal when possible.
284
+ - total_score must be an integer in [0, 100].
285
+ - answer must be exactly one of A/B/C/D.
286
+ - Do not output level/reason/x. Do not add extra keys.
287
+ - Output STRICT JSON only.
288
+
289
+ Scoring behavior:
290
+ - If severe defects exist (focus blur, broken anatomy, unstable structure, severe over/under exposure), reduce affected criteria decisively.
291
+ - Do not keep scores crowded in the middle.
292
+ - total_score should reflect overall perceptual quality and defect severity, not a fixed arithmetic formula.
293
+
294
+ Return exactly this schema:
295
+ {
296
+ "criteria": {
297
+ "Color Harmony": {"score": 0.0},
298
+ "Visual Style Consistency": {"score": 0.0},
299
+ "Sharpness": {"score": 0.0},
300
+ "Light and Shadow Modeling": {"score": 0.0},
301
+ "Creativity and Originality": {"score": 0.0},
302
+ "Exposure Control": {"score": 0.0},
303
+ "Application of Classical Composition Principles": {"score": 0.0},
304
+ "Depth of Field and Layering": {"score": 0.0},
305
+ "Visual Center Stability": {"score": 0.0},
306
+ "Visual Flow Guidance": {"score": 0.0},
307
+ "Structural Support Stability": {"score": 0.0},
308
+ "Appropriateness of Negative Space": {"score": 0.0},
309
+ "Subject Integrity": {"score": 0.0}
310
+ },
311
+ "total_score": 0,
312
+ "answer": "A|B|C|D"
313
+ }
314
+
315
+ Question: __QUESTION__
316
+ Options:
317
+ A: __OPT_A__
318
+ B: __OPT_B__
319
+ C: __OPT_C__
320
+ D: __OPT_D__
321
+
322
+ <image>""",
323
+ "v7": """You are a composition-and-lighting oriented portrait judge.
324
+
325
+ Output constraints:
326
+ - 13 criterion scores: float in [0.0, 10.0], one decimal when possible.
327
+ - total_score: integer in [0, 100].
328
+ - answer: A/B/C/D only.
329
+ - STRICT JSON only, no extra text, no extra keys, no level/reason/x.
330
+
331
+ Evaluation emphasis:
332
+ - Give strong weight to composition principles, light-shadow modeling, visual center stability, visual flow, depth layering.
333
+ - Technical failures must still be penalized clearly.
334
+ - Use a discriminative score range: clearly better images should get clearly higher total_score.
335
+ - Keep total_score globally coherent with visual impression.
336
+
337
+ Schema:
338
+ {
339
+ "criteria": {
340
+ "Color Harmony": {"score": 0.0},
341
+ "Visual Style Consistency": {"score": 0.0},
342
+ "Sharpness": {"score": 0.0},
343
+ "Light and Shadow Modeling": {"score": 0.0},
344
+ "Creativity and Originality": {"score": 0.0},
345
+ "Exposure Control": {"score": 0.0},
346
+ "Application of Classical Composition Principles": {"score": 0.0},
347
+ "Depth of Field and Layering": {"score": 0.0},
348
+ "Visual Center Stability": {"score": 0.0},
349
+ "Visual Flow Guidance": {"score": 0.0},
350
+ "Structural Support Stability": {"score": 0.0},
351
+ "Appropriateness of Negative Space": {"score": 0.0},
352
+ "Subject Integrity": {"score": 0.0}
353
+ },
354
+ "total_score": 0,
355
+ "answer": "A|B|C|D"
356
+ }
357
+
358
+ Question: __QUESTION__
359
+ Options:
360
+ A: __OPT_A__
361
+ B: __OPT_B__
362
+ C: __OPT_C__
363
+ D: __OPT_D__
364
+
365
+ <image>""",
366
+ "v8": """You are a strict cross-image ranking evaluator for portrait aesthetics.
367
+
368
+ Hard rules:
369
+ 1) criteria scores in [0.0, 10.0]
370
+ 2) total_score integer in [0, 100]
371
+ 3) answer exactly A/B/C/D
372
+ 4) strict JSON only
373
+ 5) no level/reason/x and no extra keys
374
+ 6) one decimal for criterion scores when possible
375
+
376
+ Ranking-oriented guidance:
377
+ - Avoid score collapse around 50-70.
378
+ - Apply stronger separation when quality difference is obvious.
379
+ - Penalize defects by impacted criteria instead of uniform reduction.
380
+ - total_score should be stable and monotonic with perceived overall quality.
381
+
382
+ Anchor ranges (guidance only):
383
+ - severe failure: 0-30
384
+ - weak: 31-45
385
+ - fair: 46-60
386
+ - good: 61-75
387
+ - very good: 76-88
388
+ - excellent: 89-100
389
+
390
+ Schema:
391
+ {
392
+ "criteria": {
393
+ "Color Harmony": {"score": 0.0},
394
+ "Visual Style Consistency": {"score": 0.0},
395
+ "Sharpness": {"score": 0.0},
396
+ "Light and Shadow Modeling": {"score": 0.0},
397
+ "Creativity and Originality": {"score": 0.0},
398
+ "Exposure Control": {"score": 0.0},
399
+ "Application of Classical Composition Principles": {"score": 0.0},
400
+ "Depth of Field and Layering": {"score": 0.0},
401
+ "Visual Center Stability": {"score": 0.0},
402
+ "Visual Flow Guidance": {"score": 0.0},
403
+ "Structural Support Stability": {"score": 0.0},
404
+ "Appropriateness of Negative Space": {"score": 0.0},
405
+ "Subject Integrity": {"score": 0.0}
406
+ },
407
+ "total_score": 0,
408
+ "answer": "A|B|C|D"
409
+ }
410
+
411
+ Question: __QUESTION__
412
+ Options:
413
+ A: __OPT_A__
414
+ B: __OPT_B__
415
+ C: __OPT_C__
416
+ D: __OPT_D__
417
+
418
+ <image>""",
419
+ "v9": """You are an evidence-driven portrait quality assessor.
420
+
421
+ Instruction:
422
+ - Judge visible evidence only.
423
+ - Be conservative on uncertain details, but do not under-score clearly excellent images.
424
+ - Distinguish quality levels clearly for robust ranking.
425
+
426
+ Output constraints:
427
+ - criteria scores: float [0.0, 10.0], one decimal when possible
428
+ - total_score: integer [0, 100]
429
+ - answer: one of A/B/C/D
430
+ - strict JSON only; no markdown/explanation
431
+ - no level/reason/x; no extra keys
432
+
433
+ Consistency checks (internal):
434
+ - Are low scores tied to actual flaws?
435
+ - Are high scores supported by visible strengths?
436
+ - Is total_score coherent with whole-image perception?
437
+
438
+ Schema:
439
+ {
440
+ "criteria": {
441
+ "Color Harmony": {"score": 0.0},
442
+ "Visual Style Consistency": {"score": 0.0},
443
+ "Sharpness": {"score": 0.0},
444
+ "Light and Shadow Modeling": {"score": 0.0},
445
+ "Creativity and Originality": {"score": 0.0},
446
+ "Exposure Control": {"score": 0.0},
447
+ "Application of Classical Composition Principles": {"score": 0.0},
448
+ "Depth of Field and Layering": {"score": 0.0},
449
+ "Visual Center Stability": {"score": 0.0},
450
+ "Visual Flow Guidance": {"score": 0.0},
451
+ "Structural Support Stability": {"score": 0.0},
452
+ "Appropriateness of Negative Space": {"score": 0.0},
453
+ "Subject Integrity": {"score": 0.0}
454
+ },
455
+ "total_score": 0,
456
+ "answer": "A|B|C|D"
457
+ }
458
+
459
+ Question: __QUESTION__
460
+ Options:
461
+ A: __OPT_A__
462
+ B: __OPT_B__
463
+ C: __OPT_C__
464
+ D: __OPT_D__
465
+
466
+ <image>""",
467
+ "v10": """You are an expert portrait aesthetics scorer optimized for robust cross-image ranking.
468
+ Produce high-fidelity structured scoring with strong separation.
469
+
470
+ Scoring policy:
471
+ - Use broad dynamic range where warranted.
472
+ - Penalize defects in the specific affected criteria rather than uniformly.
473
+ - Reward exceptional composition/light/style quality where clearly present.
474
+ - Keep total_score coherent with the 13-criterion profile and overall perception.
475
+
476
+ Mandatory output rules:
477
+ 1) 13 criterion scores, each in [0.0, 10.0]
478
+ 2) total_score integer in [0, 100]
479
+ 3) answer exactly A/B/C/D
480
+ 4) strict JSON only, no extra narration
481
+ 5) do not output level/reason/x, do not add extra keys
482
+
483
+ Schema:
484
+ {
485
+ "criteria": {
486
+ "Color Harmony": {"score": 0.0},
487
+ "Visual Style Consistency": {"score": 0.0},
488
+ "Sharpness": {"score": 0.0},
489
+ "Light and Shadow Modeling": {"score": 0.0},
490
+ "Creativity and Originality": {"score": 0.0},
491
+ "Exposure Control": {"score": 0.0},
492
+ "Application of Classical Composition Principles": {"score": 0.0},
493
+ "Depth of Field and Layering": {"score": 0.0},
494
+ "Visual Center Stability": {"score": 0.0},
495
+ "Visual Flow Guidance": {"score": 0.0},
496
+ "Structural Support Stability": {"score": 0.0},
497
+ "Appropriateness of Negative Space": {"score": 0.0},
498
+ "Subject Integrity": {"score": 0.0}
499
+ },
500
+ "total_score": 0,
501
+ "answer": "A|B|C|D"
502
+ }
503
+ Use one decimal place for each score when possible.
504
+
505
+ Question: __QUESTION__
506
+ Options:
507
+ A: __OPT_A__
508
+ B: __OPT_B__
509
+ C: __OPT_C__
510
+ D: __OPT_D__
511
+
512
+ <image>
513
+ """,
514
+ }
515
+
516
+
517
+ def resolve_input_path(path_arg: str) -> str:
518
+ if path_arg:
519
+ return path_arg
520
+
521
+ preferred = "outputs/predictions/track_1_test.json"
522
+ fallback = "datasets/original_annotations/track_1_test.json"
523
+ if os.path.exists(preferred):
524
+ return preferred
525
+ return fallback
526
+
527
+
528
+ def resolve_image_path(raw_path: str, image_dir: str) -> str:
529
+ raw_path = str(raw_path).strip()
530
+ if raw_path and os.path.exists(raw_path):
531
+ return os.path.abspath(raw_path)
532
+
533
+ candidate = os.path.join(image_dir, os.path.basename(raw_path))
534
+ return os.path.abspath(candidate)
535
+
536
+
537
+ def parse_variants(raw: str) -> List[str]:
538
+ variants = [x.strip() for x in str(raw).split(",") if x.strip()]
539
+ if not variants:
540
+ variants = ["v1"]
541
+
542
+ invalid = [v for v in variants if v not in PROMPT_VARIANTS]
543
+ if invalid:
544
+ raise ValueError(
545
+ f"Unknown prompt variants: {invalid}. Available: {sorted(PROMPT_VARIANTS.keys())}"
546
+ )
547
+ return variants
548
+
549
+
550
+ def build_prompt(item: Dict, variant: str) -> str:
551
+ template = PROMPT_VARIANTS[variant]
552
+ return (
553
+ template.replace("__QUESTION__", str(item["question"]))
554
+ .replace("__OPT_A__", str(item["options"]["A"]))
555
+ .replace("__OPT_B__", str(item["options"]["B"]))
556
+ .replace("__OPT_C__", str(item["options"]["C"]))
557
+ .replace("__OPT_D__", str(item["options"]["D"]))
558
+ )
559
+
560
+
561
+ def build_record(item: Dict, image_abs_path: str, variant: str) -> Dict:
562
+ return {
563
+ "messages": [
564
+ {"role": "user", "content": build_prompt(item, variant)},
565
+ {"role": "assistant", "content": ""},
566
+ ],
567
+ "images": [image_abs_path],
568
+ }
569
+
570
+
571
+ def output_path_for_variant(output_json: str, variant: str, multi_variant: bool) -> str:
572
+ if not multi_variant:
573
+ return output_json
574
+
575
+ root, ext = os.path.splitext(output_json)
576
+ if not ext:
577
+ ext = ".json"
578
+ return f"{root}_{variant}{ext}"
579
+
580
+
581
+ def main() -> None:
582
+ parser = argparse.ArgumentParser()
583
+ parser.add_argument(
584
+ "--input_json",
585
+ type=str,
586
+ default="",
587
+ help="Official test json path. If empty, auto-detect.",
588
+ )
589
+ parser.add_argument(
590
+ "--image_dir",
591
+ type=str,
592
+ default="./datasets/images",
593
+ )
594
+ parser.add_argument(
595
+ "--output_json",
596
+ type=str,
597
+ default="outputs/predictions/track1_test_convert.json",
598
+ )
599
+ parser.add_argument(
600
+ "--prompt_variants",
601
+ type=str,
602
+ default="v1",
603
+ help="Comma-separated variants, e.g. v1 or v1,v2,v3",
604
+ )
605
+ args = parser.parse_args()
606
+
607
+ input_json = resolve_input_path(args.input_json)
608
+ variants = parse_variants(args.prompt_variants)
609
+ multi_variant = len(variants) > 1
610
+
611
+ print(f"[INFO] reading test set: {input_json}")
612
+ with open(input_json, "r", encoding="utf-8") as f:
613
+ official_data = json.load(f)
614
+
615
+ os.makedirs(os.path.dirname(args.output_json), exist_ok=True)
616
+
617
+ for variant in variants:
618
+ missing_images = 0
619
+ llama_factory_data = []
620
+ for idx, item in enumerate(official_data):
621
+ img_path = resolve_image_path(item.get("image_path", ""), args.image_dir)
622
+ if not os.path.exists(img_path):
623
+ missing_images += 1
624
+ if missing_images <= 5:
625
+ print(f"[WARN][{variant}] missing image ({idx}): {img_path}")
626
+
627
+ record = build_record(item, img_path, variant)
628
+ llama_factory_data.append(record)
629
+
630
+ out_path = output_path_for_variant(args.output_json, variant, multi_variant)
631
+ with open(out_path, "w", encoding="utf-8") as f:
632
+ json.dump(llama_factory_data, f, ensure_ascii=False, indent=2)
633
+
634
+ print(f"[INFO] saved ({variant}): {out_path}")
635
+ print(f"[INFO] ({variant}) samples: {len(llama_factory_data)}, missing_images: {missing_images}")
636
+
637
+
638
+ if __name__ == "__main__":
639
+ main()
train_track1_stage1_qwen3vl8b_v1.yaml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_name_or_path: Qwen/Qwen3-VL-8B-Instruct
2
+ template: qwen3_vl_nothink
3
+ stage: sft
4
+ do_train: true
5
+ do_eval: true
6
+ finetuning_type: lora
7
+ trust_remote_code: true
8
+
9
+ dataset_dir: .
10
+ dataset: codabench_portrait_train_score_v2
11
+ eval_dataset: codabench_portrait_val_score_v2
12
+ max_samples: 1000000
13
+ cutoff_len: 4096
14
+ image_max_pixels: 786432
15
+ preprocessing_num_workers: 32
16
+ dataloader_num_workers: 8
17
+ per_device_eval_batch_size: 8
18
+ eval_strategy: steps
19
+ eval_steps: 120
20
+
21
+ learning_rate: 3.0e-5
22
+ num_train_epochs: 1.0
23
+ per_device_train_batch_size: 4
24
+ gradient_accumulation_steps: 3
25
+ lr_scheduler_type: cosine
26
+ warmup_ratio: 0.03
27
+ weight_decay: 0.01
28
+ max_grad_norm: 1.0
29
+
30
+ bf16: true
31
+ flash_attn: auto
32
+ gradient_checkpointing: true
33
+ ddp_timeout: 180000000
34
+
35
+ lora_rank: 64
36
+ lora_alpha: 128
37
+ lora_target: all
38
+ lora_dropout: 0.05
39
+
40
+ logging_steps: 10
41
+ save_steps: 120
42
+ save_total_limit: 6
43
+ plot_loss: true
44
+ overwrite_output_dir: true
45
+ report_to: none
46
+ output_dir: saves/qwen3vl8b_track1_stage1_v1
train_track1_stage2_qwen3vl8b_v1.yaml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_name_or_path: Qwen/Qwen3-VL-8B-Instruct
2
+ adapter_name_or_path: saves/qwen3vl8b_track1_stage1_v1
3
+ template: qwen3_vl_nothink
4
+ stage: sft
5
+ do_train: true
6
+ do_eval: true
7
+ finetuning_type: lora
8
+ create_new_adapter: false
9
+ trust_remote_code: true
10
+
11
+ dataset_dir: .
12
+ dataset: codabench_portrait_train_balanced_v2
13
+ eval_dataset: codabench_portrait_val_multitask_v2
14
+ max_samples: 1000000
15
+ cutoff_len: 4096
16
+ image_max_pixels: 786432
17
+ preprocessing_num_workers: 32
18
+ dataloader_num_workers: 8
19
+ per_device_eval_batch_size: 8
20
+ eval_strategy: steps
21
+ eval_steps: 120
22
+
23
+ learning_rate: 1.5e-5
24
+ num_train_epochs: 1.2
25
+ per_device_train_batch_size: 4
26
+ gradient_accumulation_steps: 3
27
+ lr_scheduler_type: cosine
28
+ warmup_ratio: 0.03
29
+ weight_decay: 0.01
30
+ max_grad_norm: 1.0
31
+
32
+ bf16: true
33
+ flash_attn: auto
34
+ gradient_checkpointing: true
35
+ ddp_timeout: 180000000
36
+
37
+ lora_rank: 64
38
+ lora_alpha: 128
39
+ lora_target: all
40
+ lora_dropout: 0.05
41
+
42
+ logging_steps: 10
43
+ save_steps: 120
44
+ save_total_limit: 6
45
+ plot_loss: true
46
+ overwrite_output_dir: true
47
+ report_to: none
48
+ output_dir: saves/qwen3vl8b_track1_stage2_v1