Autoscaling DXF Geometry from $INSUNITS in Python

Autoscaling a DXF means reading the drawing’s $INSUNITS header code, resolving it to a metre scale factor through a single authoritative lookup, and returning both the factor and a metre-normalised copy of the geometry — with an explicit, logged policy for the undefined case. A reusable autoscale_document() function removes the guesswork and the copy-paste scale tables that scatter unit bugs across a codebase. This page is part of the Unit Conversion Pipelines workflow, and it centralises the scaling step that the conversion of DXF millimetres to metres before pyproj reprojection depends on. Get the lookup and the $INSUNITS=0 policy right once, and every downstream reprojection, area calculation, and GIS export inherits correct magnitudes.

How ezdxf Exposes $INSUNITS

$INSUNITS is a header variable, not an entity property. ezdxf reads it verbatim with doc.header.get("$INSUNITS", 0), returning an integer code. Crucially, ezdxf never applies that unit to coordinates: vertices come back in raw drawing units regardless of what $INSUNITS says. The header code is metadata describing intent; converting geometry to a common unit is entirely your responsibility.

The value 0 is a first-class case, not an error. It means the originating application did not record a base unit — common in older files, minimal DXF exports, and drawings created without a template. A robust autoscale routine must decide what 0 means through explicit policy rather than a buried default. The secondary header $MEASUREMENT (0 = imperial, 1 = metric) is only a drafting-mode flag: it distinguishes inch-family from millimetre-family defaults but never names the exact unit, so it can serve only as a weak tiebreaker.

$INSUNITS Autoscale Decision Flow Decision diagram. Reading $INSUNITS branches on whether the code is known. A known non-zero code maps through the lookup table to a metre scale factor. A zero code enters the undefined-unit policy, which consults $MEASUREMENT and a configured default and logs a warning. Both branches converge on returning the scale factor and a scaled document copy. Read $INSUNITS header.get(...) code known? yes (1..7) Lookup table code → metres no (0) Undefined policy $MEASUREMENT + default + log Return scale + scaled document

Production-Ready Script

The module below defines the canonical $INSUNITS lookup, an autoscale_document() function that returns a (scale, scaled_doc) pair, and a small pytest fixture demonstrating the undefined-unit policy. Geometry is scaled in place on an in-memory copy using ezdxf’s Matrix44 uniform scale so blocks, LWPOLYLINEs, and points all move together.

# ezdxf>=1.1.0 | python>=3.9
from __future__ import annotations

import logging
from dataclasses import dataclass

import ezdxf
from ezdxf.document import Drawing
from ezdxf.math import Matrix44

log = logging.getLogger("dxf_autoscale")

# Canonical $INSUNITS -> metres lookup. Code 0 is intentionally absent: undefined.
INSUNITS_TO_METRES: dict[int, float] = {
    1: 0.0254,      # inches
    2: 0.3048,      # feet
    4: 0.001,       # millimetres
    5: 0.01,        # centimetres
    6: 1.0,         # metres
    7: 1000.0,      # kilometres
}


@dataclass
class ScaleResult:
    scale: float          # metres per drawing unit
    insunits: int         # raw header code
    undefined: bool       # True if $INSUNITS was 0 and a default was applied


def resolve_scale(
    doc: Drawing,
    default_when_undefined: float = 0.001,
    trust_measurement: bool = True,
) -> ScaleResult:
    """
    Resolve the metre scale factor for a drawing from its $INSUNITS header.
    Applies an explicit, logged policy when $INSUNITS=0 (undefined).
    """
    insunits = int(doc.header.get("$INSUNITS", 0))
    if insunits == 0:
        applied = default_when_undefined
        if trust_measurement:
            # $MEASUREMENT: 0 = imperial, 1 = metric. Weak fallback only.
            measurement = int(doc.header.get("$MEASUREMENT", 1))
            applied = 0.001 if measurement == 1 else 0.0254  # mm vs inch family
        log.warning(
            "$INSUNITS=0 (undefined). Applying default scale %.6f m/unit. "
            "Verify unit with the drawing originator.", applied,
        )
        return ScaleResult(scale=applied, insunits=0, undefined=True)

    if insunits not in INSUNITS_TO_METRES:
        raise ValueError(f"Unsupported $INSUNITS code: {insunits}")
    return ScaleResult(scale=INSUNITS_TO_METRES[insunits], insunits=insunits, undefined=False)


def autoscale_document(
    doc: Drawing,
    default_when_undefined: float = 0.001,
    write_back_header: bool = True,
) -> tuple[float, Drawing]:
    """
    Read $INSUNITS, resolve the metre scale, and return (scale, scaled_copy).
    The returned document has all modelspace geometry scaled to metres.
    """
    result = resolve_scale(doc, default_when_undefined=default_when_undefined)
    scale = result.scale

    if abs(scale - 1.0) < 1e-12:
        log.info("Drawing already in metres ($INSUNITS=%d); no scaling applied.", result.insunits)
        return scale, doc

    scaled = doc  # ezdxf documents are mutated in place; caller passes a fresh readfile()
    m = Matrix44.scale(scale, scale, scale)
    for entity in scaled.modelspace():
        if hasattr(entity, "transform"):
            entity.transform(m)  # uniform scale about the WCS origin

    if write_back_header:
        # After scaling, the drawing IS in metres -> record it as $INSUNITS=6.
        scaled.header["$INSUNITS"] = 6

    log.info("Autoscaled by %.6f m/unit (from $INSUNITS=%d, undefined=%s).",
             scale, result.insunits, result.undefined)
    return scale, scaled


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    doc = ezdxf.readfile("drawing.dxf")
    factor, metric_doc = autoscale_document(doc)
    print(f"Applied scale: {factor} m/unit")

A minimal test that exercises both a known unit and the undefined policy:

# ezdxf>=1.1.0 | pytest>=7 | python>=3.9
import ezdxf
import pytest
from dxf_autoscale import autoscale_document, resolve_scale


def _doc_with(insunits: int, measurement: int = 1):
    doc = ezdxf.new(dxfversion="R2018")
    doc.header["$INSUNITS"] = insunits
    doc.header["$MEASUREMENT"] = measurement
    doc.modelspace().add_line((0, 0), (1000, 0))  # 1000 drawing units long
    return doc


def test_millimetre_scale():
    scale, _ = autoscale_document(_doc_with(4))  # mm
    assert scale == pytest.approx(0.001)


def test_metres_no_scaling():
    scale, doc = autoscale_document(_doc_with(6))  # already metres
    assert scale == pytest.approx(1.0)


def test_undefined_uses_measurement():
    # $INSUNITS=0, $MEASUREMENT=0 (imperial) -> inch-family default
    result = resolve_scale(_doc_with(0, measurement=0))
    assert result.undefined is True
    assert result.scale == pytest.approx(0.0254)

Key implementation notes:

  • The lookup is defined once and imported everywhere; no other module should carry its own $INSUNITS table. This is what stops divergent, half-correct copies drifting apart.
  • resolve_scale() is pure and side-effect-free except for logging, so it is trivially unit-testable. autoscale_document() is the effectful wrapper that mutates geometry.
  • Matrix44.scale(scale, scale, scale) applies a uniform scale about the WCS origin. Scaling about the origin (rather than a base point) is correct here because a pure unit change must not translate geometry.
  • After scaling, the routine writes $INSUNITS=6 back so the document self-describes as metres and cannot be scaled a second time by a later stage — a structural guard against double-scaling.
  • Reading $INSUNITS safely is covered in depth by how to parse DXF headers with Python, including the fallback chains this function relies on.

Compatibility Matrix

Component Supported range Notes
ezdxf >=1.1.0 Matrix44.scale, entity.transform(), and header write-back stable since 1.0.
Python 3.9+ Uses from __future__ import annotations, dataclasses, PEP 604 hints.
DXF format R2000 (AC1015) – R2018 (AC1032) $INSUNITS present since R2000; earlier files read as 0.
$INSUNITS codes 1, 2, 4, 5, 6, 7 Common linear units mapped; extend the table with additional codes as your sources require.
$MEASUREMENT 0 or 1 Weak metric/imperial flag; used only as an undefined-unit tiebreaker.
Geometry types Any entity with transform() Entities lacking transform() (some proxies) are skipped; log and review them.

Fallback Strategies

1. Undefined units ($INSUNITS=0). Apply a configured default, use $MEASUREMENT as a weak metric/imperial tiebreaker, and log the applied factor with the file name. Surface undefined=True in the result so callers can route the file to manual verification rather than trusting it silently.

2. Per-INSERT unit overrides. A block definition can carry its own units, and AutoCAD scales inserted blocks whose units differ from the drawing. autoscale_document() scales the drawing uniformly and does not resolve block-level unit mismatches. If your blocks were authored in different units, flatten and apply the block-to-drawing ratio before the drawing-to-metre scale, exactly as when converting DXF millimetres to metres before pyproj reprojection.

3. Survey vs. architectural defaults. When the unit is undefined, the safe default depends on discipline: architectural and mechanical drawings default to millimetres, civil and survey drawings to metres. Make the default configurable per ingestion source rather than hard-coding one global assumption.

4. Audit logging. Every autoscale decision — the code read, the factor applied, whether it was a default — must be logged and ideally persisted alongside the output. Unit errors are silent by nature; the audit log is often the only way to reconstruct why a dataset was off by a factor of a thousand weeks later.

5. Entities without transform(). Some proxy or custom entities do not implement transform() and are skipped by the scale loop, leaving them at raw magnitude. Count skipped entities and fail the job if the count is non-zero for a file you expect to be fully scalable.

FAQ

What does each $INSUNITS value mean?

$INSUNITS is an integer code for the drawing’s base unit: 0 undefined, 1 inches (0.0254 m), 2 feet (0.3048 m), 4 millimetres (0.001 m), 5 centimetres (0.01 m), 6 metres (1.0 m), 7 kilometres (1000 m). Higher codes cover miles, yards, microns, and others. Map the code to a metre scale factor before any spatial computation.

How should I handle $INSUNITS=0?

Treat 0 as undefined, never as a silent millimetre default. Apply an explicit configured policy default, inspect $MEASUREMENT (0 imperial, 1 metric) as a weak secondary signal, log a warning with the file name, and record the assumed unit in your audit trail so it can be reviewed later.

Is $MEASUREMENT a reliable unit source?

No. $MEASUREMENT only distinguishes imperial (0) from metric (1) drafting settings and does not identify the specific unit. Use it only as a weak tiebreaker when $INSUNITS is undefined — for example choosing a metric versus imperial default — never as the primary unit determinant.

Does ezdxf rescale INSERT block geometry automatically?

No. ezdxf does not auto-scale inserted blocks that carry a different unit from the drawing. If a block definition has its own units, apply the block-to-drawing ratio yourself during flattening, then apply the drawing-to-metre factor once. Autoscaling the header unit alone does not resolve per-INSERT unit overrides.