fix algining xynova texture to uv printing fixture.

This commit is contained in:
ykcheungab
2026-09-29 16:33:31 +08:00
parent 267334d543
commit 37e3e8400b
33 changed files with 758477 additions and 886701 deletions
+209
View File
@@ -0,0 +1,209 @@
"""Per-view page sizing derived from the projection (stdlib only).
The problem
-----------
A pair's texture page comes from ``texture_patch.vector``, one hand-entered size shared by
every layout. But each production layout was authored by *Project from View (Bounds)* from its
own camera, so ``[0,1]^2`` of that layout means a **different physical size** in each one. On
`finger-xynova/r01` the layouts are 21.199 x 23.200 mm while the page says 18.5 x 16.4 mm —
23% wrong, and inverted from portrait to landscape.
`_warn_if_page_aspect_differs` cannot catch this: it compares the raster aspect against the
page aspect, and those two agree. The page is self-consistent and wrong about the geometry.
The derivation
--------------
For a layout that *is* a bounds-projection from a camera, ``u`` is an affine function of the
camera's image-plane ``x`` and ``v`` of ``y``. So fitting each axis recovers the mapping
exactly, and the page is its inverse:
u = a * x + b page_width_mm = 1 / |a|
v = c * y + d page_height_mm = 1 / |c|
The fit's **residual is the proof** that the layout really is such a projection. Measured
1.2e-7 on the real `uv_1`/`uv_2` layouts, against 0.17-0.70 for `produce`/`compute`, which are
unwraps and must keep a hand-set page. So the derivation verifies before it trusts, and refuses
rather than silently falling back — a silently-kept page is precisely the defect being fixed.
Two details that matter
-----------------------
* The camera's **object scale must be divided out**. `finger-xynova`'s cameras carry scale
0.014988; measuring with ``matrix_world.inverted()`` reports a 1414 mm extent. The caller must
pass image-plane coordinates measured through the scale-normalised view matrix, which is what
`patch_projection.build_patch_geometry` already uses — so the page agrees with the geometry
the generator actually clips.
* The page is set from the **rounded raster**, ``page = raster_px / px_per_mm``, so page aspect
and raster aspect are equal by construction and the existing uniform-scale guard cannot fire.
The cost is at most ``0.5 / px_per_mm`` = 0.0025 mm at 200 px/mm, two orders of magnitude
below the 0.1 mm alignment tolerance.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Sequence
#: What both shipped revisions already use exactly: 3280/16.4, 3200/16.0, 3700/18.5 and
#: 3280/16.4 are all 200.0, so only the proportion changes, not the density.
DEFAULT_PX_PER_MM = 200.0
#: A true bounds-projection fits to ~1e-7; an unwrap lands at 0.17-0.70. The gap is five orders
#: of magnitude, so the threshold is not delicate.
DEFAULT_RESIDUAL_TOLERANCE = 1e-5
#: Bounds fitting puts the UV box exactly on [0,1]^2.
DEFAULT_BBOX_TOLERANCE = 1e-5
MIN_PAGE_MM = 1e-3
class PageSizingError(ValueError):
"""The layout is not a verifiable bounds-projection, so no page can be derived."""
@dataclass(frozen=True)
class DerivedPage:
"""A verified page/raster pair for one layout."""
page_width_mm: float
page_height_mm: float
raster_width_px: int
raster_height_px: int
px_per_mm: float
slope_u: float
slope_v: float
residual_u: float
residual_v: float
uv_min: tuple[float, float]
uv_max: tuple[float, float]
@property
def page_aspect(self) -> float:
return self.page_width_mm / self.page_height_mm
@property
def raster_aspect(self) -> float:
return self.raster_width_px / self.raster_height_px
def describe(self) -> str:
return (
f"page {self.page_width_mm:.3f} x {self.page_height_mm:.3f} mm "
f"raster {self.raster_width_px} x {self.raster_height_px} px "
f"({self.px_per_mm:g} px/mm)"
)
def _fit(target: Sequence[float], source: Sequence[float]) -> tuple[float, float, float]:
"""Least-squares ``target = slope * source + intercept``; returns max residual too."""
count = len(source)
if count < 2:
raise PageSizingError(f"need at least 2 samples to fit an axis, got {count}")
mean_source = sum(source) / count
mean_target = sum(target) / count
sxx = sum((value - mean_source) ** 2 for value in source)
if sxx <= 0.0:
raise PageSizingError("the mesh has no extent along one axis of this view")
sxy = sum(
(value - mean_source) * (other - mean_target)
for value, other in zip(source, target)
)
slope = sxy / sxx
intercept = mean_target - slope * mean_source
residual = max(
abs(other - (slope * value + intercept)) for value, other in zip(source, target)
)
return slope, intercept, residual
def derive_page(
uv_samples: Iterable[Sequence[float]],
plane_samples: Iterable[Sequence[float]],
px_per_mm: float = DEFAULT_PX_PER_MM,
residual_tolerance: float = DEFAULT_RESIDUAL_TOLERANCE,
bbox_tolerance: float = DEFAULT_BBOX_TOLERANCE,
) -> DerivedPage:
"""Derive and verify the page for one layout.
``uv_samples`` and ``plane_samples`` are parallel sequences of ``(u, v)`` and the
corresponding camera image-plane ``(x, y)`` in millimetres, one entry per mesh loop.
Raises `PageSizingError` — never warns and carries on — when the layout is not a
bounds-projection from that camera.
"""
uvs = [(float(u), float(v)) for u, v in uv_samples]
planes = [(float(x), float(y)) for x, y in plane_samples]
if len(uvs) != len(planes):
raise PageSizingError(
f"uv_samples and plane_samples must be parallel: {len(uvs)} vs {len(planes)}"
)
if not uvs:
raise PageSizingError("no samples; the mesh has no loops")
if not isinstance(px_per_mm, (int, float)) or px_per_mm <= 0:
raise PageSizingError(f"px_per_mm must be > 0 (got {px_per_mm!r})")
slope_u, _intercept_u, residual_u = _fit(
[u for u, _ in uvs], [x for x, _ in planes]
)
slope_v, _intercept_v, residual_v = _fit(
[v for _, v in uvs], [y for _, y in planes]
)
if abs(slope_u) <= 0.0 or abs(slope_v) <= 0.0:
raise PageSizingError("a UV axis does not vary with the image plane; cannot size")
if residual_u > residual_tolerance or residual_v > residual_tolerance:
raise PageSizingError(
"this layout is not a bounds-projection from the named camera, so no page can be "
f"derived: u-vs-x max residual {residual_u:.3g}, v-vs-y max residual "
f"{residual_v:.3g} (tolerance {residual_tolerance:g}). Unwrapped layouts such as "
"'compute' land at 0.1-0.7 here. Set `page: fixed` for this pair to keep the "
"page from texture_patch.vector."
)
u_min = min(u for u, _ in uvs)
u_max = max(u for u, _ in uvs)
v_min = min(v for _, v in uvs)
v_max = max(v for _, v in uvs)
for name, low, high in (("u", u_min, u_max), ("v", v_min, v_max)):
if abs(low) > bbox_tolerance or abs(high - 1.0) > bbox_tolerance:
raise PageSizingError(
f"the layout's UV bounding box is not [0,1]^2 in {name}: "
f"[{low:.6f}, {high:.6f}] (tolerance {bbox_tolerance:g}), so it was not "
"fitted to the view bounds and a page cannot be derived"
)
# abs() because a mirrored axis still has a well-defined size; the sign only says the UV
# runs opposite to the image plane, which no page needs to encode.
page_width_mm = 1.0 / abs(slope_u)
page_height_mm = 1.0 / abs(slope_v)
if page_width_mm < MIN_PAGE_MM or page_height_mm < MIN_PAGE_MM:
raise PageSizingError(
f"degenerate page {page_width_mm:.6g} x {page_height_mm:.6g} mm; the mesh is "
"edge-on to this camera"
)
# Page from the rounded raster, so the two aspects are equal by construction.
raster_width_px = max(1, int(round(page_width_mm * float(px_per_mm))))
raster_height_px = max(1, int(round(page_height_mm * float(px_per_mm))))
return DerivedPage(
page_width_mm=raster_width_px / float(px_per_mm),
page_height_mm=raster_height_px / float(px_per_mm),
raster_width_px=raster_width_px,
raster_height_px=raster_height_px,
px_per_mm=float(px_per_mm),
slope_u=slope_u,
slope_v=slope_v,
residual_u=residual_u,
residual_v=residual_v,
uv_min=(u_min, v_min),
uv_max=(u_max, v_max),
)
__all__ = [
"DEFAULT_BBOX_TOLERANCE",
"DEFAULT_PX_PER_MM",
"DEFAULT_RESIDUAL_TOLERANCE",
"MIN_PAGE_MM",
"DerivedPage",
"PageSizingError",
"derive_page",
]
+102 -6
View File
@@ -58,6 +58,7 @@ from texture_merge import ( # noqa: E402
validate_seam,
)
import seam_gap # noqa: E402
import page_sizing # noqa: E402
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1"
@@ -297,9 +298,20 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})"
)
page_block = job.get("page") or {}
if not isinstance(page_block, dict):
raise ConfigError("job 'page' must be a mapping")
page_mode = str(page_block.get("mode", "auto"))
if page_mode not in ("auto", "fixed"):
raise ConfigError(f"job 'page.mode': must be 'auto' or 'fixed' (got {page_mode!r})")
px_per_mm = float(page_block.get("px_per_mm", page_sizing.DEFAULT_PX_PER_MM))
if px_per_mm <= 0.0:
raise ConfigError(f"job 'page.px_per_mm': must be > 0 (got {px_per_mm!r})")
return {
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
"base_fill": base_fill,
"page": {"mode": page_mode, "px_per_mm": px_per_mm},
"gap": {
"enabled": gap_enabled,
"apply": gap_apply,
@@ -525,6 +537,34 @@ def _triangle_seam_edges(
return out
def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm):
"""Derive and verify one pair's page from the geometry its layout projects.
Uses the same scale-normalised view matrix `patch_projection` clips with, so the page
agrees with the geometry that is actually textured. Dividing out the camera's object scale
is not optional: `finger-xynova`'s cameras carry 0.014988, which would otherwise report a
1414 mm extent for a 19 mm part.
"""
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
layer = mesh.uv_layers.get(uv_name)
if layer is None:
raise SceneError(f"UV layer {uv_name!r} is missing from the evaluated mesh")
view = camera.matrix_world.normalized().inverted()
to_world = evaluated.matrix_world
uvs: list[tuple[float, float]] = []
planes: list[tuple[float, float]] = []
for loop in mesh.loops:
u, v = layer.data[loop.index].uv
local = view @ (to_world @ mesh.vertices[loop.vertex_index].co)
uvs.append((float(u), float(v)))
planes.append((float(local.x), float(local.y)))
return page_sizing.derive_page(uvs, planes, px_per_mm=px_per_mm)
finally:
evaluated.to_mesh_clear()
def _mesh_centroid_world(obj, depsgraph):
"""Area-weighted centroid of the evaluated surface, in world mm.
@@ -682,6 +722,20 @@ def main() -> int:
f"per view "
+ ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items()))
)
if result["hole_fraction"] > policy.max_hole_fraction:
# An unowned region prints nothing, so it is a visible defect, not a statistic. This is
# the gate that turns "the surface has a bald patch" into a message naming the cause.
print(
f"ERROR: hole fraction {result['hole_fraction']:.4f} exceeds max_hole_fraction "
f"{policy.max_hole_fraction:g}: {result['hole_mm2']:.3f} mm^2 of the surface "
f"({meta['triangle_count'] - len(owner)} triangles) has no owning view, so it "
"would print nothing. The usual cause is the incidence limit: a view whose surface "
"is steeper than `incidence_limit_deg` cannot own it. Raise that limit (or re-pose "
f"the region cameras), or set `max_hole_fraction` above {result['hole_fraction']:.4f} "
"to accept the gap deliberately.",
file=sys.stderr,
)
return 1
# ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------
# The seam is passed here (not to the region pass) because only the emitted geometry needs
@@ -758,6 +812,44 @@ def main() -> int:
detail(f" colour seed {pair['seed']}")
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
# ---- page: derived per view, or inherited from texture_patch ----------------
pair_page_mode = str(pair.get("page") or config["page"]["mode"])
if pair_page_mode == "auto":
try:
derived = _derive_pair_page(
obj, region_cam, uv_map, depsgraph, config["page"]["px_per_mm"]
)
except (page_sizing.PageSizingError, SceneError) as exc:
# Never fall back silently: keeping the shared page is what produced a 23%
# inverted aspect on finger-xynova.
print(
f"ERROR: cannot auto-size the page for view {pair_id!r}: {exc}",
file=sys.stderr,
)
return 1
texture_width = derived.raster_width_px
texture_height = derived.raster_height_px
page_width_mm = derived.page_width_mm
page_height_mm = derived.page_height_mm
page_info = {
"page_source": "auto",
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
"raster_px": [texture_width, texture_height],
"page_residual": [derived.residual_u, derived.residual_v],
"page_slope": [derived.slope_u, derived.slope_v],
}
detail(f"[{pair_id}] {derived.describe()} (derived from {pair['camera']})")
else:
texture_width = config["width"]
texture_height = config["height"]
page_width_mm = config["vector_width_mm"]
page_height_mm = config["vector_height_mm"]
page_info = {
"page_source": "fixed",
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
"raster_px": [texture_width, texture_height],
}
patches = pattern_geometry[uv_map]["patches"]
mesh_layer = pattern_geometry[uv_map]["mesh"]
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
@@ -855,6 +947,7 @@ def main() -> int:
"uv_map": uv_map,
"region_camera": pair["camera"],
"pattern_camera": pair["pattern_camera"],
**page_info,
"owned_triangles": len(owned_triangles),
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
"inked_triangles": len(kept_triangles),
@@ -901,13 +994,13 @@ def main() -> int:
nx=config["nx"],
ny=config["ny"],
r=config["color_r"],
texture_width=config["width"],
texture_height=config["height"],
texture_width=texture_width,
texture_height=texture_height,
output_path=pair["artifacts"]["png"],
svg_path=pair["artifacts"]["svg"],
pdf_path=pair["artifacts"]["pdf"],
vector_width_mm=config["vector_width_mm"],
vector_height_mm=config["vector_height_mm"],
vector_width_mm=page_width_mm,
vector_height_mm=page_height_mm,
uv_centre=uv_centre,
cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
@@ -968,8 +1061,11 @@ def main() -> int:
"settings": {
"nx": config["nx"],
"ny": config["ny"],
"raster": [config["width"], config["height"]],
"page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
# The fallback page/raster for pairs with `page: fixed`; with `page: auto` the
# per-view values below are authoritative.
"default_raster": [config["width"], config["height"]],
"default_page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
"page": dict(config["page"]),
"seed": config["seed"],
},
"ownership": {
+190
View File
@@ -0,0 +1,190 @@
"""Run with python -m pytest blender/scripts/test_page_sizing.py (no Blender needed).
The derivation is only usable because its own residual proves the layout is a
bounds-projection. These tests pin both halves: that a real projection recovers the measured
page, and that an unwrap is refused rather than silently sized.
"""
import unittest
from page_sizing import (
DEFAULT_PX_PER_MM,
DerivedPage,
PageSizingError,
derive_page,
)
#: Measured on the shipped blends (scale-normalised view matrix, so the camera object scale
#: is divided out). These are the numbers the feature exists to reproduce.
FINGER_UV_1_MM = (17.5116, 15.9772)
FINGER_UV_2_MM = (17.5114, 15.9820)
XYNOVA_UV_1_MM = (21.1989, 23.2003)
XYNOVA_UV_2_MM = (21.2001, 23.2003)
def projection_samples(width_mm, height_mm, steps=40, v_flip=False):
"""Samples of a bounds-projection: UV fills [0,1]^2 over a `width x height` view plane."""
uvs = []
planes = []
for i in range(steps + 1):
for j in range(steps + 1):
x = width_mm * i / steps
y = height_mm * j / steps
u = i / steps
v = (1.0 - j / steps) if v_flip else (j / steps)
uvs.append((u, v))
planes.append((x, y))
return uvs, planes
class DerivationTests(unittest.TestCase):
def test_recovers_the_finger_r01_uv_1_page(self):
page = derive_page(*projection_samples(*FINGER_UV_1_MM))
self.assertAlmostEqual(page.page_width_mm, FINGER_UV_1_MM[0], places=2)
self.assertAlmostEqual(page.page_height_mm, FINGER_UV_1_MM[1], places=2)
def test_recovers_every_measured_page(self):
for width_mm, height_mm in (
FINGER_UV_1_MM, FINGER_UV_2_MM, XYNOVA_UV_1_MM, XYNOVA_UV_2_MM
):
with self.subTest(size=(width_mm, height_mm)):
page = derive_page(*projection_samples(width_mm, height_mm))
self.assertAlmostEqual(page.page_width_mm, width_mm, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, height_mm, delta=0.01)
def test_xynova_page_is_portrait_where_the_configured_page_was_landscape(self):
"""The defect in one assertion: 0.914 vs the configured 1.128."""
page = derive_page(*projection_samples(*XYNOVA_UV_1_MM))
self.assertLess(page.page_aspect, 1.0)
self.assertAlmostEqual(page.page_aspect, 0.9137, places=3)
configured = 18.5 / 16.4
self.assertGreater(abs(configured - page.page_aspect), 0.2)
def test_real_slopes_round_trip(self):
"""Feeding the slopes AND ranges measured off the blend recovers the measured extent.
The measured `uv_1` affine is u = 0.057105098 x + 0.472297765 over x in
[-8.270676, 9.240895]; the intercept is only consistent with that range, which is why
a synthetic range would fail the [0,1]^2 bounds check.
"""
x_min, x_max = -8.270676, 9.240895
y_min, y_max = -7.890431, 8.086782
slope_u, intercept_u = 0.057105098, 0.472297765
slope_v, intercept_v = 0.062589139, 0.493855294
uvs, planes = [], []
for i in range(101):
x = x_min + (x_max - x_min) * i / 100.0
y = y_min + (y_max - y_min) * i / 100.0
uvs.append((slope_u * x + intercept_u, slope_v * y + intercept_v))
planes.append((x, y))
page = derive_page(uvs, planes)
self.assertAlmostEqual(page.page_width_mm, 17.5116, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, 15.9772, delta=0.01)
def test_residual_is_essentially_zero_for_a_projection(self):
page = derive_page(*projection_samples(20.0, 10.0))
self.assertLess(page.residual_u, 1e-12)
self.assertLess(page.residual_v, 1e-12)
def test_mirrored_v_axis_is_still_sized(self):
page = derive_page(*projection_samples(20.0, 10.0, v_flip=True))
self.assertAlmostEqual(page.page_width_mm, 20.0, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, 10.0, delta=0.01)
class VerificationTests(unittest.TestCase):
def test_unwrap_is_refused_and_names_the_escape_hatch(self):
"""A non-affine u-vs-x map is the `compute` case in miniature."""
uvs, planes = [], []
for i in range(101):
x = i * 0.1
uvs.append((x / 10.0 + 0.05 * (x / 10.0) ** 2, i / 100.0))
planes.append((x, i * 0.1))
with self.assertRaises(PageSizingError) as caught:
derive_page(uvs, planes)
message = str(caught.exception)
self.assertIn("not a bounds-projection", message)
self.assertIn("page: fixed", message)
def test_residual_tolerance_boundary(self):
uvs, planes = projection_samples(20.0, 20.0, steps=20)
# Nudge a single sample just inside, then just outside, the tolerance.
for offset, should_pass in ((0.5e-5, True), (5e-5, False)):
nudged = list(uvs)
nudged[7] = (nudged[7][0] + offset, nudged[7][1])
if should_pass:
derive_page(nudged, planes)
else:
with self.assertRaises(PageSizingError):
derive_page(nudged, planes)
def test_uv_box_not_unit_square_is_refused(self):
"""A layout fitted to something other than the view bounds has no derivable page."""
uvs, planes = projection_samples(20.0, 10.0)
squashed = [(u * 0.5, v) for u, v in uvs]
with self.assertRaises(PageSizingError) as caught:
derive_page(squashed, planes)
self.assertIn("[0,1]^2", str(caught.exception))
def test_flat_axis_is_refused(self):
planes = [(x, 0.0) for x, _ in ((i * 0.1, 0.0) for i in range(11))]
uvs = [(i / 10.0, i / 10.0) for i in range(11)]
with self.assertRaises(PageSizingError) as caught:
derive_page(uvs, planes)
self.assertIn("no extent", str(caught.exception))
def test_too_few_samples_is_refused(self):
with self.assertRaises(PageSizingError):
derive_page([(0.0, 0.0)], [(0.0, 0.0)])
def test_mismatched_lengths_are_refused(self):
with self.assertRaises(PageSizingError) as caught:
derive_page([(0.0, 0.0), (1.0, 1.0)], [(0.0, 0.0)])
self.assertIn("parallel", str(caught.exception))
def test_bad_px_per_mm_is_refused(self):
uvs, planes = projection_samples(20.0, 10.0)
for bad in (0, -1.0, "many"):
with self.assertRaises(PageSizingError):
derive_page(uvs, planes, px_per_mm=bad)
class InvariantTests(unittest.TestCase):
def test_page_and_raster_aspects_are_equal_by_construction(self):
"""This is what stops `_warn_if_page_aspect_differs` firing."""
for width_mm, height_mm in (
(17.5116, 15.9772), (21.1989, 23.2003), (1.0, 1.0), (100.0, 3.0), (3.0, 100.0)
):
with self.subTest(size=(width_mm, height_mm)):
page = derive_page(*projection_samples(width_mm, height_mm))
self.assertAlmostEqual(page.page_aspect, page.raster_aspect, places=12)
def test_page_is_the_rounded_raster_over_the_density(self):
page = derive_page(*projection_samples(17.5116, 15.9772))
self.assertAlmostEqual(
page.page_width_mm, page.raster_width_px / DEFAULT_PX_PER_MM, places=12
)
self.assertAlmostEqual(
page.page_height_mm, page.raster_height_px / DEFAULT_PX_PER_MM, places=12
)
def test_default_density_reproduces_the_shipped_rasters(self):
"""200 px/mm is what 3280x16.4 and 3700x18.5 already are, exactly."""
self.assertEqual(DEFAULT_PX_PER_MM, 200.0)
self.assertAlmostEqual(3280 / 16.4, 200.0, places=9)
self.assertAlmostEqual(3200 / 16.0, 200.0, places=9)
self.assertAlmostEqual(3700 / 18.5, 200.0, places=9)
def test_rounding_error_stays_far_below_the_alignment_tolerance(self):
"""At 200 px/mm the page can be off by at most 0.0025 mm; tolerance is 0.1 mm."""
page = derive_page(*projection_samples(*XYNOVA_UV_1_MM))
self.assertLessEqual(abs(page.page_width_mm - XYNOVA_UV_1_MM[0]), 0.5 / 200.0)
self.assertLessEqual(abs(page.page_height_mm - XYNOVA_UV_1_MM[1]), 0.5 / 200.0)
def test_describe_is_readable(self):
page = derive_page(*projection_samples(20.0, 10.0))
self.assertIn("page 20.000 x 10.000 mm", page.describe())
self.assertIn("200 px/mm", page.describe())
if __name__ == "__main__":
unittest.main()