- name
- blockwright-gate
- description
- Этап 5 Blockwright, числа — гейт, отчёт, рендеры и листы сравнения. Используй при работе с gate.py, blockwright.gate, Registration, сечениями по эталону, checks.inspect, report.json, render_set, compare.iou, вердиктом ungraded — и когда гейт красный или, наоборот, подозрительно зелёный.
# Этап 5 — числа
Проверок нужно четыре, и ловят они разное: **гейт** меряет то, что сводится к
числу; **рендер** показывает то, что не сводится; **лист** ставит результат рядом
с тем, из чего он начерчен; **фото-ревью** — это то, что кто-то действительно на
это посмотрел (см. `blockwright-review`).
## Гейт
**Оценивать по карте сходство — тавтология.** Пятно туда вошло, оно, конечно,
оттуда и выйдет; перевёрнутое вверх ногами здание набрало бы столько же.
Но оценивать по карте **соответствие** — не тавтология: карта тут
спецификация, а не свидетель, и вопрос «построилось ли то, что план говорит»
законный. Так работают `COUNTS`, `RINGS` и `<часть> is built square`. Правило не
«не сравнивай с картой», а «строка обязана говорить, какой из двух вопросов она
закрыла»; зелёная строка соответствия про сходство не говорит ничего. Подробно —
`docs/sources.md`, «Сходство и соответствие».
Честная проверка **сходства** использует вход, который никогда не подавался как
геометрия.
Меш годится: он читается ради чисел и никогда ради формы. Поэтому гейт сводит и
меш, и готовую схематику к одному и тому же — высшая точка на каждой станции
поперёк здания — и сравнивает станцию со станцией на отрезке, где план является
простым блоком во всю ширину.
Допуск — около двух метров, потому что собственный шум меша примерно с блок.
Сравнение ограничивается тем, где меш действительно держит здание: у краёв клипа
он держит деревья и дорогу, и сравнение там меряет клип-бокс.
На реальном примере это поймало систематическую ошибку 1.7 м по обеим крышам:
настил был положен на верхнюю плиту, а меш ясно показывал кровельный пирог метром
выше.
## Три вердикта, а не два
`pass`, `fail` и `ungraded`. Последний — не мягкий `pass`: он говорит, что всё, на
что можно было ответить, отвечено верно, и что часть вопросов осталась без
ответа. Это другое здание, чем то, которое проверили и оно устояло.
| что положили | что делает гейт |
|---|---|
| план с карты, разрез по захвату | режет разрез — настоящая внешняя проверка |
| модель и захват | режет по захвату: он план не рисовал |
| план и разрез из одного OBJ | режет разрез, но пишет `[----] independent witness` |
| ни модели, ни захвата | `[----] section` — резать не по чему |
| пустой `COUNTS` | `[----] division` — никто не сказал, на сколько частей делится |
| окно без станций | `[----] <окно>` — эталон над ним ничего не держит |
Строка `[----]` — это вопрос, на который нечем ответить, и она печатается вместо
того, чтобы проверку молча пропустить. Пропущенная молча проверка читается ровно
как пройденная, а разница между ними — это разница между оценённой постройкой и
неосмотренной.
Во второй строке таблицы разрез всё равно полезен: он оценивает **упрощение** —
часть с покатой крышей или со ступенчатым крылом на нём падает. Чего он не может
— поймать ошибку самого эталона, потому что ничего не придержали, чтобы ему
возразить. Что закрыло бы этот пробел: второй источник. Карта рядом с моделью,
фотографии рядом с захватом.
## Чего гейт не видит
Сказать прямо, потому что форма его слепоты решает, что придётся проверять иначе.
Профиль по максимуму высоты молчит обо всём под силуэтом, обо всём фасаде, о
полостях, о чём угодно висящем в воздухе и о любом сдвиге вдоль длины здания.
Две другие секции закрывают то, что он не может:
- **«Целое ли оно»** отвечается вообще ни против чего — `blockwright.checks`:
связность с землёй, свободные концы элементов, счёт компонент на этаж. Меш тут
не помощник: отломившийся и зависший каркас навеса стоит ровно там, где меш
говорит быть навесу, поэтому сечение через него меряется правильным, пока вещь
видимо разваливается.
- **«Всё ли на месте»** отвечается расписанием. Ни одна из двух других спросить
этого не может: обе оценивают материал, который существует, а отказ здесь — это
материал, которого нет.
- **«Согласно ли здание само с собой»** — строка `evenness`, `checks.twins` и
`checks.level_runs`. Все остальные строки сравнивают постройку с чем-то
снаружи, и поэтому ни одна не видит этого класса: две башни одного пятна дают
один силуэт и один профиль, что бы ни творилось на их фасадах, а парапет,
построенный на девяти высотах вдоль одной крыши, проходит каждую станцию, на
которой его судят, — он с этих станций и снят. И то и другое — первое, что
видит человек на рендере.
Таблицы в `gate.py`: `TWINS` — плановые части, которые обязаны быть зеркальной
парой (ось берётся между ними), `LEVEL` — сколько разных верхних отметок
разрешено части. Плановая часть, а не объявленная: объявление сливает все
вызовы, которые его сделали, поэтому «parapets» на здании со стилобатом и
двумя башнями — это три разные горизонтали в одной маске, и счёт по ним не
значит ничего.
Пустой `TWINS` — `ungraded`, а не молчание, ровно как пустой `COUNTS`; здание
без повторов говорит `SINGULAR = True`.
**`SINGULAR` — правда, и поэтому вопрос задаётся вторым способом.** Пять зданий
из шести объявили его честно: крылья разной длины, корпуса непохожи, зеркала
действительно нет. Повторяется в этих зданиях не отражение, а **сдвиг** —
этажи, ряды вилл, секции фасада, — и про него `TWINS` не спрашивает. Поэтому
рядом стоит строка `<часть> stands at that storey`, которой таблица не нужна
вовсе: `checks.cadence` мерит период построенной стены по её собственному
контурному кольцу (`Mask.outline`) и сверяет с этажом из `derived["storeys"]`,
домноженным на `derived["stretch"]["vertical"]` — тем же множителем и в том же
порядке, что и `Site.storeys`.
Отвечает **период**, а не сила корреляции: стена с шагом 3-2-3-3-2 коррелирует
хорошо, но на периоде 5 или 8. Счёт — только порог (`CADENCE_CLEAR`), ниже
которого стена уходит в `ungraded` строкой «walls without a rhythm». Кольцо
читается дважды — по силуэту и по материалу, — и берётся сильное чтение
(`by` говорит какое): полоса перекрытия или спандрель силуэт не режет.
Единственная таблица здесь только **вычитает** части: `NOT_WALLS` — плановые
части, у которых этажей нет по существу (свод раздвижной кровли, рельс, плита
плаза). Имя, которого план не рисует, останавливает гейт — переименованная
часть не должна тихо вернуться в оценку. Выключенные части печатаются строкой,
как у `JAGGED`.
**Бюджет — это цель, а не то, что вышло.** Правило про «каждая красная строка
либо зелёная, либо объяснена числом» действует и здесь: опустить `LEVEL` до
семи, потому что вышло семь, — это проверка, переставшая быть проверкой.
Выключатель тоже по частям, а не по зданию: `JAGGED` принимает не только
число, но и словарь `{часть: допуск}` с `"*"` на остальные. `JAGGED = None`
из-за одной дуги гасит контроль прямизны у трёх прямоугольных крыльев рядом,
и это уже стоило здания. Выключенные части печатаются строкой `[----] drawn
straight`.
- **«Копии — это правда копии?»** — строка `<часть> repeats`, `checks.copies`.
То, что `division` спрашивает про форму, спрошенное про материал: восемь
одинаковых секций и восемь секций, нарисованных по одним и тем же числам,
дают один силуэт, одно сечение, один план, одни доли материалов и один аудит.
Разница видна с земли и ни одной другой строке.
Сравнение **побайтовое, без допуска вообще**. Бюджет «почти совпадает»
впускает обратно ровно тот цикл, ради которого строка есть: он и отличается от
копии на горсть клеток на секцию.
Одно исключение названо, а не измерено: `Canvas.finalize` раздаёт панелям,
заборам и стенкам состояния по соседям, а у крайних копий соседи другие.
Клетки на риме мотива, принадлежащие семейству из `blocks.connects`,
сравниваются по имени без состояния, и каждая считается и печатается.
Заявляется в манифесте (`Item(..., copies=N)`) и рядом со штампом
(`sched.declare_repeat`). Пустой список — `[----] repetition`, как пустой
`COUNTS`; закрывается `UNREPEATED` с **причиной**, не флагом.
- **«Прямой ли угол»** — строка `<часть> is built square`, `checks.corners` и
`checks.rectangular`. Дефект вносит сам пайплайн: `Site.footprint` закрывает
трассированную маску и раздвигает падом, обе операции евклидовы, а евклидова
дилатация — диск, поэтому каждый выпуклый угол приезжает откушенным на
`pad + CLOSE`. Не видит этого никто: скруглённый угол даёт тот же силуэт, ту
же станцию, ту же площадь, а `jaggedness` меряет форму против её же
выпрямленного чтения и фаску в четыре клетки переживает при любом допуске.
Спрашивается только у частей, которые **карта** нарисовала прямоугольником
(`SQUARE_SAME`), и читается с плиты постройки, обрезанной по маске самой части.
Плита ищется по самой постройке и обязана стоять **над воздухом**
(`CORNERS_ALOFT`): без этого здание в один этаж, мостящее террасу на отметке
первого этажа, грейдит собственный грунт — то есть карту — против карты и
проходит всегда. Мера — **метры от угла** (`checks.corner_reach`,
`CORNERS_BITE`), оба чтения против **нарисованной** коробки: угол, который
скруглил сам картограф, так вычитается. Долей коробки это не мерится — четыре
скушенных угла дают шесть десятых процента на части в 1200 клеток, а ритм
фасада, которого в плане не было, — восемь; доля печатается рядом и не
грейдится. Строит прямоугольник `Site.squared`; часть, которая прямоугольником
не является, но составлена из прямых, — `Site.faceted` (углы там выходят
пересечениями измеренных граней и прямые по построению).
- **«Там ли она вообще стоит»** — строка `<часть> stands where the plan says` по
каждой плановой части: `missing` (сколько нарисованного не занято, грейдится,
`PLAN_MISSING`) и `outside` (сколько материала вне всего плана — печатается,
грейдится только при объявленном `PLAN_OUTSIDE`, потому что балкон и навес
свешиваются законно). Рядом пишется `out/compare/plan-vs-build.png`: серое —
сошлось, тёплое — план нарисовал, постройка не встала, холодное — постройка
вышла за план. Смотреть картинку, а не только процент: «4 % не построено» не
говорит, какие четыре процента.
- **«Та же ли это стена»** — строки `facades`, `facades built alike` и
`the elevations agree about the bay`. `FACADES_DIFFER` спрашивает только то,
что кто-то уже заподозрил; уезжает обратное — два участка, которые постройка
сделала одной стеной. Матрица `checks.mix_matrix` считается каждый прогон по
отрезкам, нарезанным автоматически, если `FACADES` пуст, и печатается целиком.
`UNIFORM` принимает **причину**, а не `True`: голый флаг стоил одну строку и
гасил единственную проверку, смотрящую на стену.
- **«Одна ли это высота»** — строка `<часть> is one height`. Каждая строка ниже
берёт у части одно число — медиану скайлайна, — и медиана это высота только
тогда, когда станции складываются в одну палубу. Башня, свисающая над
переходом, стоит над его пятном: переход в два этажа, а вниз по конвейеру
уезжает двадцать восемь метров. Разброс `low..high` этого не ловит — парапет,
венткамера и шахта лифта дают такой же, — ловит **доля станций у медианы**
(`PLATEAU`, этаж). Никогда не фейл: это свидетельство об эталоне и о плане.
Закрывается `PLATEAUS[часть] = "чем это объясняется"` или разрезанием части
в плане.
- **«Та ли территория»** — строки `<поверхность> covers its ground` и
`<поверхность> is a rectangle` по таблицам `GROUNDS` и `RECTANGULAR`. Клип
ведут по участку, и строка `the clip holds the site` спрашивает, дотянулся
ли эталон до постройки. Форму двора, дороги и пляжа захват по-прежнему не
свидетельствует: сверяются площадь и габарит против полосы высот в
`derived["grounds"]`. Не форма.
- **«Удержал ли холст эталон»** — строка `the canvas holds the reference`.
`assemble` отказывает куску, у которого хоть одна клетка не легла на холст
плана, и пишет его в `assembly.off_plan`. Гейт читает эту запись и не
перемеряет: непустой список краснеет (сколько кусков, сколько площади, два
выхода — расширить кроп или обрезать эталон), пустой зеленеет с нулями, а
не молчит. Таблицы извинения нет: имена раздаёт `CAPTURE_PARTS` по месту
среди выживших, а отказанный кусок — не выживший. Одна фраза на здание
спрятала бы виллы за изгородью.
## Сечение режется по нескольким окнам, а не по одному
Сечение через равномерную выдавку одинаково, где его ни режь. Всё, что реально
ломалось, ломалось в хвостах. По умолчанию `windows()` берёт окна оттуда, где
части перестают перекрываться по u: два параллельных крыла — одно окно (рез
через них один и тот же), башня за концом крыла — своё. Дробить дальше и
склеивать вручную там, где здание само подсказывает.
**Верх блока, а не индекс.** Блок в слое y занимает [y, y+1), и захват меряет
верх кровли. Сравнение по индексу стоило бы метра на каждой станции в одну
сторону — половина допуска, потраченная до первой настоящей ошибки.
## Четыре неочевидные части `blockwright.gate`
- **Регистрация перед сравнением.** У клипа меша своя вписанная рама, не делящая
с картой ни начала, ни размаха. Сопоставляются размахи того, что стоит выше
некоторой отметки, потом две оси сверяются между собой. Если подгонка вышла
1.15× по u и 0.98× по v, она зацепилась не за здание — **гейт так и говорит и
не режет сечения**, вместо того чтобы оценивать шум.
В `derive` отметка — **высота в метрах** (`REGISTER_FLOOR`), а не доля верха
эталона: план описывает всё здание, включая одноэтажное крыло, и доля
выбросила бы всё, что ниже половины самой высокой части. Стилобат с башней при
этом регистрируют разные здания и падают с сообщением про переклип, который ни
при чём. В гейте обе стороны фильтруются одинаково, там доля допустима.
- **Исключения по имени, а не допуском.** Там, где меш заведомо не свидетель —
фотограмметрия скругляет тонкий сходящий на нет кончик конуса, — покрываемые
станции извиняются поимённо, с напечатанной рядом причиной и счётом. Расширение
допуска извинило бы заодно и всё остальное. Исключение, которое ничего не
покрывает, само сообщается как watch: геометрия, ради которой его писали,
переехала, и никто не заметил.
- **Свежесть, фатально.** Протухшая схематика регистрируется идеально — это была
настоящая сборка того же здания, — поэтому каждая строка после неё продолжает
печатать оценку, верную вчера. Гейт сравнивает mtime первым делом и
останавливается.
- **Швы.** Материал в паре клеток от края окна ничей и не оценивается. Стена под
52° растеризуется с 1.4 клетки неоднозначности, и регистрация оставляет
невязку примерно того же размера.
Гейт живёт в библиотеке, а не в здании. Сначала он был поскриптовым — у реального
примера 592 строки, из которых про само здание примерно шестьдесят, — а
скопированный гейт гниёт в каждой копии по-своему, что хуже отсутствия гейта:
копии продолжают печатать зелёное. В `gate.py` здания остаются только таблицы:
`TOLERANCE`, `REGISTER_AT` (в гейте — доля), бюджеты, `COUNTS`, `UNDIVIDED`,
`LEVELS`, `RINGS`,
`WATERTIGHT` и функция `windows()`, задающая отрезки для сечений.
## Свидетели
Где два входа могут ответить на один вопрос, отвечают оба, а разница ложится в
`derived.json` числом с допуском. Гейт читает эти числа и делает из них строки —
сам он ничего не перемеряет.
| строка | что сравнивает | что ловит |
|---|---|---|
| `extent agree` | габарит плана против эталона | пересэмплированный кроп, клип не того здания |
| `bearing agree` | углы двух рам | развёрнутая или перепутанная ось |
| `part heights agree` | высоты по модели и по захвату | модель другой ревизии, крыло без съёмки |
| `storey height agree` | измеренный этаж против объявленного | чертёж, прочитанный не в том масштабе |
| `plan overlap agree` | IoU двух планов | два контура одного здания, которые не совпадают |
| `scale` | \|scale − 1\| постройки против эталона | пересэмплированная карта, эталон не того здания |
Расхождение, которое является фактом о входах, а не дефектом — игровая карта
против захвата реального прототипа, — объявляется в `EXPECTED` в
`probes/derive.py`: строка становится `[----]` с причиной, а не падает. Допуск
не трогается: широкий настолько, чтобы проглотить 37 градусов, проглотил бы и
раму, севшую на деревья.
**Куда повёрнуто — решает регистрация, по данным.** Протяжение одинаково с
любого конца, поэтому две рамы одного здания могут оказаться антипараллельны, и
всё сойдётся, кроме того, что каждое окно измерится на другом конце.
`Registration.orient` перебирает четыре варианта и берёт тот, где совпадает
профиль ширины вдоль и поперёк. Отрыв лидера пишется в `derived.json`.
Строка `scale` отвечает на другой вопрос, чем `registration`: та спрашивает,
согласны ли оси между собой, эта — равно ли это единице. Постройка на четверть
меньше эталона по обеим осям регистрируется идеально и режется идеально.
## Порядок сходимости сечения
Правки двигают станции в обе стороны, и кажется, что процесс не сходится. Он
сходится, если идти в этом порядке — снизу вверх по стоимости ошибки:
1. **Верх материала.** Стены до `T-1`, венчание вниз от `T`, а не вверх от него.
Блок в слое `y` занимает [y, y+1). Одна ошибка, напечатанная тридцать три
раза.
2. **Одна регистрация, а не две.** `Registration.measured(derived)`, а не
собственный фит гейта.
3. **Единицы станций.** Сечение бинует эталон по целым метрам **меша** и
отчитывается как `to_build_v(k + 0.5)`. Пробник, который бинует по своим
plan-метрам, промахивается на целую станцию при масштабе 1.07.
4. **Форма ступеней.** Высота ступени — середина размаха, а не медиана: сечение
судит худшую станцию, а не типичную.
5. **И только потом исключения.**
**Два прогона подряд не сдвинули отчёт — не двигай окно вслепую, запусти
`--profile`.** `python -m buildings.<имя>.gate --profile` печатает вместо
вердикта саму съёмку: обе линии горизонта по всем станциям окна, в одних
единицах (build v), одной таблицей, отсортированной по v. Оценённые строки
идут как обычно, остальные — с причиной: `under the clutter floor`, `the plan
claims nothing here`, `nothing in the reference at this station`, `past the end
of the reference clip`. Полоса последней причины с одного конца — участок
больше захвата, это норма; станция без эталона **внутри** клипа — дыра в
захвате или кусок постройки, которого никто не просил. Обычный вывод показывает
только оценённые станции, поэтому «40 из 96» не говорит, какие сорок, — отсюда
и берётся цикл «подвинуть окно, получить другое число». В `report.json` те же
причины лежат счётчиками в `sections[].dropped`.
## Ловушка медианы
Высота части, снятая медианой скайлайна по её пятну, врёт на всём, что сужается
кверху или стоит на чём-то другом. Ротонда на палубе бассейна вернула 19.7 м
вместо 24.3; плоская крыша с машинным помещением возвращает высоту будки.
`derive` теперь печатает разброс по каждой части — палуба, медиана, диапазон — и
говорит прямо, когда одна высота эту часть не описывает. Инструменты:
`roof.terraces` для ступенчатой кровли, `roof.plateau` для узкой части (читать
поперёк, а не сверху), `roof.plant` для надстроек.
## Отчёт
Консольный вывод существует, пока терминал не прокрутится. Дефект, который жил
только там, нельзя отследить между прогонами, нельзя посчитать и нельзя показать
исправленным.
`blockwright.report` пишет один JSON на прогон, держащий каждое произведённое число:
каждую проверку с её вердиктом, каждую станцию, где сечение разошлось, аудит
расписания, счёт блоков, отщепенцев, свободные концы, перекрытия силуэтов. Ничего
не сводится в сводку — сводить консоль и так умела. Сам он ничего не оценивает.
Единственное, что он добавляет, — плоский список дефектов, выдернутых из разных
структур, чтобы у вопроса «что не так с этой сборкой» был один ответ в одном
месте, а не прогулка по четырём объектам.
## Рендеры
Гейт слеп ко всему, чего не меряет. Долгое время единственными видами были
плоские силуэты, а ряд из тринадцати вилл и один сплошной брус дают **одинаковый
силуэт** — сборка читалась правильной, будучи неправильной самым заметным
способом. Оба отказа на реальном примере пережили каждое посчитанное пайплайном
число и умерли в тот момент, когда что-то нарисовало блоки блоками.
`blockwright/render.py` рисует холст или схематику ортографической камерой, живущей
в собственной раме здания, поэтому рендер можно поставить рядом с орто из Google
Earth под тем же углом. Блоки остаются кубами на мировой сетке, стоящей под 52° к
этой раме, поэтому ступенька видна — именно её увидел бы стоящий там человек.
Грани рисуются от дальних к ближним, что для вокселей точно: осевые единичные
кубы не могут пересечься, поэтому у сортировки по глубине нет циклов.
Три детали решают, читается ли картинка:
- **Отбрасывание соседей.** Грань выпускается только там, где граничит с
воздухом. У сплошной сборки это большинство граней.
- **Свет обязан разделять крышу и стену.** При слишком высоком свете верхняя
грань и затенённая стена сходятся в пределах процентов, и стык двух объёмов
становится невидим — то есть ровно тот отказ, который ищут.
- **Швы принадлежат виду.** Обводка каждого блока показывает ряды на косом виде.
При взгляде вдоль оси рамы ни одна грань не параллельна экрану, каждая стена —
диагональная лестница, и обводка каждой ступеньки — шум. Флаг несёт вид,
`render` ему подчиняется.
**Рендер моделирует каждый блок полным непрозрачным кубом**, что верно сегодня и
станет ложью в момент, когда откроется словарь блоков. Стекло уже загораживает
то, что за ним; плиты читались бы на полметра выше; стеклянная балюстрада
нарисовалась бы сплошным парапетом — то есть отказ «тринадцать домов читаются
одним брусом», воспроизведённый точь-в-точь. Рендер — единственный инструмент,
поймавший настоящий дефект, поэтому прозрачность и полувысота **предшествуют**
любому некубическому блоку, а не следуют за ним.
**Незнакомый блок обязан быть видимо незнакомым.** Безопасного цвета по умолчанию
не бывает: серый фолбэк однажды нарисовал целый конус стеновым серым и спрятал его
на виду на целую итерацию.
## Листы сравнения
`blockwright/compare.py` ставит сборку рядом с тем, из чего она начерчена, **в одном
масштабе**, с десятиметровой линейкой снизу. Всё там работает в метрах на пиксель:
у блендеровского орто своё число в `render_meta.json`, у рендера — `1 / scale`, у
фотографии никакого, и она подгоняется по высоте с подписью «(not to scale)».
`compare.iou` оценивает два силуэта, предварительно совмещённых по габаритной
рамке, поэтому меряет форму и пропорцию, а не положение — положение это работа
гейта, и он делает её в метрах. Это **число, за движением которого следят между
прогонами, а не оценка**: силуэты приходят из разных вещей и единицы не достигнут
никогда.
Листы говорят то, чего не могут числа. На реальном примере лист плана показал,
что центральный двор в меше — **бассейн**, там где сборка кладёт пол, и что
ротонда действительно круглая с дыркой посередине, там где сборка ставит
прямоугольник.
View on GitHub