Files
aetherbound-guild/runtime/tools/m88_shp002_visual_probe.gd
T

165 lines
6.5 KiB
GDScript

extends SceneTree
## M88 gate: SHP-002 recruit-review header must sit inside the authored frame.
## Frame geometry measured on the static master (PIL scan, 1280x720): top line
## y=44..46, left line x=44..46, right line x=1233..1236 (inner edge 1232).
const ReviewView = preload("res://entry/views/recruit_review_page_view.gd")
const InkCheck = preload("res://tools/ui_ink_check.gd")
const FIXTURE := Vector2i(1280, 720)
const LOCALES := ["zh_CN", "en"]
const TEXT_SCALES := [1.0, 1.3]
const LINE_TOP := Rect2(0, 44, 1280, 3)
const LINE_LEFT := Rect2(44, 0, 3, 720)
const LINE_RIGHT := Rect2(1233, 0, 3, 720)
const INNER := Rect2(47, 47, 1232 - 47, 720 - 47 - 47)
const PRICE_RIGHT_MAX := 1228.0
const TITLE_TOP_MIN := 50.0
const CONTRAST_MIN := 4.5
const NAME_INK := Color("#e8d39d")
var failures: Array = []
var out_dir := ""
var gated_scans := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
out_dir = OS.get_environment("ABG_EVIDENCE_DIR")
if out_dir.is_empty():
push_error("M88 probe requires ABG_EVIDENCE_DIR")
quit(1)
return
for locale in LOCALES:
for scale in TEXT_SCALES:
await _capture(String(locale), float(scale))
if failures.is_empty():
print("ABG_M88_SHP002_HEADER_OK locales=2 scales=1.0,1.3 inner=%s price_right_max=%.0f title_top_min=%.0f" % [
str(INNER), PRICE_RIGHT_MAX, TITLE_TOP_MIN])
quit(0)
return
for failure in failures:
push_error("M88 SHP-002 header: %s" % failure)
print("ABG_M88_SHP002_HEADER_FAIL failures=%d" % failures.size())
quit(1)
func _capture(locale: String, text_scale: float) -> void:
var sub := SubViewport.new()
sub.size = FIXTURE
sub.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(sub)
var view := ReviewView.new()
view.name = "M88ReviewView"
view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
view.configure_presentation({"safe_area": 24, "text_scale": text_scale, "locale": locale})
sub.add_child(view)
view.render(_data(locale))
for _i in range(3):
await process_frame
sub.get_texture().get_image().save_png(out_dir.path_join("shp002-1280x720-%s-ts%.1f-final.png" % [locale, text_scale]))
var header_safe := view.find_child("RecruitReviewHeaderSafe", true, false) as Control
if header_safe == null:
failures.append("%s ts%.1f header safe missing" % [locale, text_scale])
sub.queue_free()
return
var labels := _labels(header_safe)
if labels.size() < 3:
failures.append("%s ts%.1f header exposes %d labels" % [locale, text_scale, labels.size()])
sub.queue_free()
return
var title: Label = labels[0]
var name_label: Label = labels[1]
var price: Label = labels[2]
var frame_scale := 1.0
var gated := is_equal_approx(text_scale, 1.0)
if gated:
gated_scans += 1
for pair in [["title", title], ["name", name_label], ["price", price]]:
var node_label := String(pair[0])
var label: Label = pair[1]
var rect := label.get_global_rect()
var ink: Rect2 = InkCheck.ink_rect(label)
if gated and not INNER.encloses(rect):
failures.append("%s rect %s escapes the frame inner %s" % [node_label, str(rect), str(INNER)])
if gated and not INNER.encloses(ink):
failures.append("%s ink %s escapes the frame inner %s" % [node_label, str(ink), str(INNER)])
for line_pair in [["top", LINE_TOP], ["left", LINE_LEFT], ["right", LINE_RIGHT]]:
if gated and (rect.intersects(line_pair[1]) or ink.intersects(line_pair[1])):
failures.append("%s crosses the %s frame line" % [node_label, String(line_pair[0])])
if gated:
if price.get_global_rect().end.x > PRICE_RIGHT_MAX:
failures.append("price right edge %.0f exceeds %.0f" % [price.get_global_rect().end.x, PRICE_RIGHT_MAX])
if InkCheck.ink_rect(title).position.y < TITLE_TOP_MIN:
failures.append("title ink top %.1f is above %.0f" % [InkCheck.ink_rect(title).position.y, TITLE_TOP_MIN])
var ratios := PackedStringArray()
for pair in [["title", title], ["name", name_label], ["price", price]]:
var ratio: float = _contrast_for(view, pair[1])
ratios.append("%s=%.2f:1" % [String(pair[0]), ratio])
if ratio < CONTRAST_MIN:
failures.append("%s contrast %.2f:1 below %.1f:1" % [String(pair[0]), ratio, CONTRAST_MIN])
print("ABG_M88_SHP002_CONTRAST %s ts%.1f %s" % [locale, text_scale, " ".join(ratios)])
sub.queue_free()
await process_frame
func _contrast_for(view: Control, label: Label) -> float:
# Carrier = the pixels immediately around the glyph band (inside the label rect,
# outside the ink), median luminance. A P95 over the ink rect would sample the
# bright glyphs themselves for gold text and report a false 1:1.
var image := view.get_viewport().get_texture().get_image()
var rect := label.get_global_rect()
var samples: Array = []
var y_top := clampi(int(rect.position.y) + 2, 0, image.get_height() - 1)
var y_bottom := clampi(int(rect.end.y) - 2, 0, image.get_height() - 1)
for x in range(clampi(int(rect.position.x) + 2, 0, image.get_width() - 1),
clampi(int(rect.end.x) - 2, 1, image.get_width())):
samples.append(image.get_pixel(x, y_top))
samples.append(image.get_pixel(x, y_bottom))
var x_left := clampi(int(rect.position.x) + 2, 0, image.get_width() - 1)
var x_right := clampi(int(rect.end.x) - 2, 0, image.get_width() - 1)
for y in range(y_top, maxi(y_top + 1, y_bottom)):
samples.append(image.get_pixel(x_left, y))
samples.append(image.get_pixel(x_right, y))
if samples.is_empty():
return 0.0
samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
var carrier: Color = samples[samples.size() / 2]
var ink: Color = label.get_theme_color("font_color")
var lighter := maxf(_luminance(ink), _luminance(carrier))
var darker := minf(_luminance(ink), _luminance(carrier))
return (lighter + 0.05) / (darker + 0.05)
func _luminance(color: Color) -> float:
var linear: Array = []
for value in [color.r, color.g, color.b]:
linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4))
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
func _labels(node: Node) -> Array:
var found: Array = []
for child in node.get_children():
if child is Label and (child as Label).text.strip_edges() != "":
found.append(child)
found.append_array(_labels(child))
return found
func _data(locale: String) -> Dictionary:
var is_zh := locale == "zh_CN"
return {
"ok": true,
"locale": locale,
"offer": {"offer_id": "offer-001", "list_price": 14},
"facts": {"name": "阿里" if is_zh else "Ari", "profession_id": "PF-B01", "trait_id": "TR-001"},
"availability": {"available": true},
"impact": {"owned_before": 2, "owned_after": 3, "line_capacity": 4},
"hire": {"eligible": true, "price": 14},
"contract": {},
}